Skip to content

Commit 933ea61

Browse files
feat(scripts,adr): a published TYPE-surface narrowing gets an honest ADR-0087 disposition — the sixth category, paid for by a base-side type reading (#13596)
* feat(scripts,adr): a published TYPE-surface narrowing gets an honest ADR-0087 disposition — the sixth category, paid for by a base-side type reading `check-adr-0087-registration` had no disposition a published TYPE-surface narrowing could answer honestly. Measured on the real #12104 shape, driven through the shipping `scan()`: `registered` refuses (no id is new, and writing one puts false data in the ledger), `unpublished` refuses (`@objectstack/client` publishes), `no-migration-prescription` refuses (the body carries a FROM/TO table), `runtime-interface-only` inherits that same refusal — and `already-registered` is *admitted*, which is worse: the gate verifies the id resolves and pre-dates the base, never that the entry covers the change, so the only thing closing that door is the author's honesty. The exit the gate names in its own refusal text is factually false for this class, so the move that remained was to drop the `**BREAKING**` token. Four changesets took it (#8140, #11925, #12034, #12104). A whole recurring class of genuinely compile-breaking change was being nudged away from this repo's breaking-change marker, purely because the marker routed it into a ledger it does not belong in — invisibly, with every gate green. ## The sixth category `not-required (type-surface-only <path>#<Symbol>)`, admitted on four predicates, all four, named rather than counted (`TYPE_SURFACE_PREDICATES`): published every bumped package really publishes — the exact inverse of `unpublished`, same manifests. no-spec-diff the diff touches no `packages/spec/**` path. no-metadata-surface-diff the diff moves no `*.zod.ts`, no spec `contracts/**` entry, no object definition. narrowed-from-erased the named symbol reads `any` / `unknown` / unannotated at the MERGE BASE and concrete at HEAD. This is the only category exempt from the `no-migration-prescription` refusal. The exemption is necessary — without it the gate still refuses the exact class the category serves — and predicate 4 is what pays for it. Predicates 1-3 alone were measured NOT to separate #6048 from #12104: both come out published, no spec movement, no metadata surface. The prescription refusal is the only guard holding #6048 out, so exempting a category from it while checking only 1-3 would hand the founding case a green exit. Predicate 4 refuses it on a positive, re-runnable reading rather than a detector miss (the #8299 method): #6048's `ActorUser` is a concretely typed exported interface at base that LOST a member; this class starts from `any`. ## Machinery Reuses `parseSymbolRef` / `exportedTypeDeclaration`, per the ruling; no TS-aware diff reader. One new sibling reader, `memberReturnAnnotation`, because three of the four live instances narrow a METHOD RETURN and not an exported type — a category that could not read that shape would be born unable to serve its own population. It reads one named symbol in one file at one rev, and looks at no diff. String and comment spans are masked through `js-comment-mask.mjs`. ## Coverage +55 self-test assertions (237 -> 292): a red case for each of the four predicates, each moving exactly ONE predicate off true; the all-four-true green; the predicate set pinned BY NAME plus a pin that the exported names are the ones `verifyTypeSurfaceOnly` actually evaluates; the exemption asserted both ways; and the #6048 regression pin in two layers — a unit pin reading the REAL `packages/runtime/src/security/actor-user.ts` out of this tree, and a scan-level pin over a two-commit reconstruction of the #6048 diff. The ADR half is inseparable: the vocabulary is pinned bidirectionally, and both directions were re-measured on this branch. This PR touches a governed surface and is deliberately left as a draft for a human merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC * fix(scripts): the type-surface-only fixture demonstrates its OWN subject, so its migration table stops reading as an SDK call site The `type-surface-only` fixture's changeset body carried a real FROM/TO migration table -- correctly, since this is the one ADR-0087 category exempt from the prescription refusal -- but the table rewrote `client.analytics.query`, a method the fixture neither declares nor names. `TSO_REF` names `queryDataset` and `TSO_BASE_CLIENT` declares `queryDataset`, so the table was prescribing a migration this fixture's diff does not ship. That incoherence also ejected the PR from the merge queue. `packages/client/src/envelope-caller-census.test.ts` walks the whole repo for the four dispatcher-served SDK methods and masks COMMENTS ONLY -- never string literals. The table is a string literal, so its two `analytics.query(` spellings were counted as real SDK call sites and the census ledger went red on a count it has no row for (expected 21 to be 19). Aligning the table to the fixture's own symbol fixes both at once, and the direction is the honest one for it: the `/analytics/dataset/query` route is served bare by `@objectstack/rest`, so there is no envelope, `.data.rows` is a spelling `Promise< any >` used to permit, and the new annotation refuses it -- a prescription the COMPILER delivers, which is exactly what this category is for. The fixture keeps a genuine migration table; nothing in the census, its ledger, or the ADR was touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 52ef672 commit 933ea61

2 files changed

Lines changed: 956 additions & 10 deletions

File tree

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

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ gate's log and its `--list` output.
584584
<!-- adr-0087: not-required (already-registered <id>[, <id>...]) <why> -->
585585
<!-- adr-0087: not-required (no-migration-prescription) <why> -->
586586
<!-- adr-0087: not-required (runtime-interface-only <path>#<Symbol>[, ...]) <why> -->
587+
<!-- adr-0087: not-required (type-surface-only <path>#<Symbol>[, ...]) <why> -->
587588
```
588589

589590
**The vocabulary is closed, and every exemption is re-verified on every run** — an
@@ -592,8 +593,9 @@ avoid. `registered` must name ids that resolve *and* are new in the diff;
592593
`unpublished` requires every bumped package to be `private: true`;
593594
`already-registered` requires the named ids to pre-date the merge base;
594595
`no-migration-prescription` is refused by a body that carries a migration
595-
prescription; and `runtime-interface-only` is the subject of the rest of this
596-
addendum. The checks live in
596+
prescription; `runtime-interface-only` is the subject of the rest of this
597+
addendum; and `type-surface-only` — the sixth, added 2026-08-30 — is the subject of
598+
the addendum after it. The checks live in
597599
[`scripts/check-adr-0087-registration.mjs`](../../scripts/check-adr-0087-registration.mjs),
598600
which is also where each one's measured history is written down.
599601

@@ -667,3 +669,133 @@ a verdict unless the categories listed above and the categories it accepts are t
667669
same set, checked in both directions. A category added to the gate and described
668670
nowhere is an exemption an author cannot look up; a category described here that the
669671
gate rejects is an exemption nobody can claim. Both are red.
672+
673+
## Addendum (2026-08-30) — the sixth category: a published TYPE-surface narrowing (#13080)
674+
675+
The vocabulary above gained a sixth answer:
676+
677+
```text
678+
<!-- adr-0087: not-required (type-surface-only <path>#<Symbol>[, ...]) <why> -->
679+
```
680+
681+
### The dead end it closes
682+
683+
A **published TYPE-surface narrowing** — a published SDK method whose declared
684+
return moves off `any` onto the contract it always answered — declares
685+
`**BREAKING**` truthfully. `any` is assignable to everything and admits every
686+
property read, so a consumer's code really can stop compiling. The gate then asks
687+
for a disposition, and every one of the five is closed to it. Measured on the real
688+
#12104 shape, driven through the shipping gate:
689+
690+
| disposition | outcome |
691+
|---|---|
692+
| `registered` | refused — no id is new in the diff, and writing one would put a prescription in the ledger that `objectstack migrate meta`, `spec-changes.json` and the upgrade guide cannot project. False data in the one ledger this whole mechanism keeps true. |
693+
| `not-required (unpublished)` | refused — `@objectstack/client` publishes to npm. |
694+
| `not-required (already-registered <id>)` | the gate *admits* it, and that is the problem: it verifies the id RESOLVES and PRE-DATES the base, never that the entry COVERS this change. The only thing closing this door is the author's honesty, which is not a mechanism. |
695+
| `not-required (no-migration-prescription)` | refused — the body carries a FROM/TO block. |
696+
| `not-required (runtime-interface-only …)` | refused — it inherits that same refusal. |
697+
698+
So the exit the gate names in its own refusal text — *"use a category that can be
699+
verified (`unpublished`, `already-registered`)"* — is factually false here, and the
700+
only move left is to **drop the `**BREAKING**` token**.
701+
702+
### ⛔ The four changesets that took that move are a COUNTER-EXAMPLE, not a precedent
703+
704+
`#8140`, `#11925`, `#12034` and `#12104` are all `@objectstack/client` return-type
705+
narrowings, all shipped `minor`, all recording the no-entry disposition in prose,
706+
none carrying the token — each one consistent with the one before it. Read
707+
individually every one of those calls was reasonable. Read together they are the
708+
finding: a whole recurring class of genuinely compile-breaking change was nudged
709+
away from this repo's breaking-change marker, purely because the marker routed it
710+
into a ledger it does not belong in. The erosion is invisible — every gate stayed
711+
green throughout.
712+
713+
**With the category in place the prose flips back.** A published TYPE-surface
714+
narrowing resumes honestly carrying `**BREAKING**` and answers the gate with
715+
`type-surface-only`. ⛔ Dropping the token is no longer an available exit, and those
716+
four are cited here so the next author reads them as the shape to stop repeating.
717+
718+
### D8 — the affected party is a TypeScript consumer, and the compiler is the channel
719+
720+
The ADR-0087 ledger serves **metadata upgraders**: `objectstack migrate meta`
721+
rewrites stored metadata, and it has nothing to reach when the only thing that moved
722+
is a type annotation. The affected party here is a **TypeScript consumer** and the
723+
delivery channel is the **compiler at their own call site** — strictly more precise
724+
than a ledger line, and it reaches every affected consumer rather than the subset
725+
who read release notes.
726+
727+
`no-migration-prescription` is not wrong in general — #6048 is exactly the shape it
728+
was built to catch — it simply cannot tell *a prescription for a metadata upgrader*
729+
from *a prescription for a source-code consumer*, and only the first is the ledger's
730+
business.
731+
732+
An author may claim this category when **all four** of these hold, and the gate
733+
checks all four by name (`published`, `no-spec-diff`, `no-metadata-surface-diff`,
734+
`narrowed-from-erased`):
735+
736+
1. **the package really publishes** — the exact inverse of `unpublished`, read from
737+
the same workspace manifests. A private package's break reaches no consumer, so
738+
the compiler is not the channel that makes this honest; `unpublished` is.
739+
2. **the diff does not touch `packages/spec/**`** — that package *is* the contract
740+
between metadata producers and the runtime, and both ADR-0087 registries live
741+
inside it.
742+
3. **no ADR-0087 shape surface moved in the diff** — no `*.zod.ts`, no
743+
`packages/spec/src/contracts/**` entry, no object definition. Those are exactly
744+
what `objectstack migrate meta` reaches; when one of them moved, the ledger *is*
745+
a channel that can carry the change.
746+
4. **the named symbol was erased at the merge base and is concrete at HEAD** — for
747+
each `<path>#<Symbol>` the author names, the gate reads its declared type at
748+
*both* revs and requires `any` / `unknown` / no annotation at base, and a
749+
concrete type at HEAD.
750+
751+
### ⭐ Predicate 4 is what makes this a narrowing rather than a hole
752+
753+
This is the **only** category exempt from the `no-migration-prescription` refusal.
754+
The exemption is necessary — measured on the card, without it the gate still refuses
755+
the exact class the category was created for — and it is also the only place in the
756+
vocabulary where something the prescription detector refuses today becomes
757+
claimable. That is not free, and predicate 4 is what pays for it.
758+
759+
Predicates 1–3 alone were measured against the case this gate was **founded** on and
760+
they do not separate it: on reconstructed diffs the #12104 shipping half and the
761+
**#6048** shape come out identical on all three — published, no `packages/spec`
762+
movement, no metadata surface — so both would be admitted. And the prescription
763+
refusal is the only guard currently holding #6048 out. Exempting a category from it
764+
while checking only 1–3 would hand the founding case a green exit: a measured
765+
regression, not a hypothetical.
766+
767+
Predicate 4 refuses it on a **positive, re-runnable reading** rather than on a
768+
detector miss — the same method D7 used. #6048's symbol
769+
(`packages/runtime/src/security/actor-user.ts#ActorUser`) is a concretely typed
770+
exported interface at the merge base that *lost* a runtime member; the class this
771+
category serves starts from `any` / `unknown` / no annotation at all. The base-side
772+
type is the reading that separates them.
773+
774+
**Never weaken predicate 4 to make something else pass.** It is not one check of
775+
four; it is the reason the other three are allowed to skip the prescription refusal
776+
at all. A regression pin in the gate's `--self-test` asserts that the #6048 shape is
777+
refused by predicate 4 specifically, so the founding case can never become
778+
claimable.
779+
780+
#### What this does not decide
781+
782+
**Whether the HEAD type is the *right* type.** Whether the bound contract is what
783+
the route actually answers is a question about a producer, settled by driving it —
784+
which is what the changesets in this class do. This category asks only whether the
785+
surface moved *off* an erased type.
786+
787+
**Whether the awaited type resolves to `any` through a chain.** Predicate 4 is a
788+
source-text reading of what the author wrote at two revs, which is what *"was it
789+
narrowed in this diff"* means. Its neighbour `check:exported-any-returns` judges the
790+
complementary question — *does it still resolve to `any`* — against the **built
791+
dist**, precisely because the erasure is invisible in source when a method carries
792+
no annotation. The division is deliberate.
793+
794+
**The completeness of the claim.** Like `registered` and `runtime-interface-only`,
795+
the gate judges the claim that was made, not whether the author named every symbol
796+
their PR touched.
797+
798+
**The dispositions of the four live instances.** Those are backfilled after this
799+
lands, per the ruling, and one of them (`declare-search-clone-response-contracts`)
800+
fails predicates 2 and 3 outright — it bumps `@objectstack/spec` and adds
801+
`packages/spec/src/api/protocol.zod.ts`, so it is not a member of this class.

0 commit comments

Comments
 (0)