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
Conversation
…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
⏸ Standing status: green for 10 hours, and the only thing left is a human merge
Why no AI seat has landed it. The diff touches
⛔ This seat is still not flipping it ready, and the reason is case law rather than caution: ⇒ Waiting on the maintainer. The question of whether green governed PRs should be marked ready Generated by Claude Code |
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33403389633 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⭐ Root-caused: #13596 is the real regression. #13797 and #13824 are bystanders — ⛔ do not re-queue them as "flake"Diagnosed by the The finding
#13596 adds this to '| `(await client.analytics.query(q)).rows` | `(await client.analytics.query(q)).data.rows` |\n';⇒ two Measured with the repo's own masker, ⛔ not by eyeRunning
⭐ And the failing assertion is
What follows
⭐ This is precisely the shape #13849's own body warns about: "Start with the root above; an Generated by Claude Code |
⛔ Do NOT re-arm on the strength of this PR's green checksState right now: PR-side CI runs only the AFFECTED subset. The merge-queue build runs the FULL suite. ⇒ ⛔ This PR is not ready to re-queue until the fix is pushed. A re-queue now ejects it again and rebuilds everything behind it. ⭐ The general reading, worth more than this instance: "all checks green" answers a narrower question than "this will merge." A gate that is not in the affected set is not a gate that passed — it is a gate that did not run. A fix is dispatched (card #13080, on this branch — ⛔ no new PR). This comment will be superseded by that push. Generated by Claude Code |
…ect, 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
Fixes #13080
The dead end, re-measured before anything was built
A published TYPE-surface narrowing — a published SDK method whose declared return moves off
anyonto the contract it always answered — declares**BREAKING**truthfully:anyisassignable to everything and admits every property read, so a consumer's code really can stop
compiling.
check-adr-0087-registrationthen demands a disposition, and none is available.Driven through the shipping
scan()on the real #12104 shape (script in the report on the card):registered SOME-IDobjectstack migrate meta,spec-changes.jsonand the upgrade guide cannot projectnot-required (unpublished)@objectstack/clientpublishes to npmnot-required (already-registered SOME-ID)not-required (no-migration-prescription)not-required (runtime-interface-only ...)So the exit the gate names in its own refusal text — "use a category that can be verified
(
unpublished,already-registered)" — is factually false for this class, and the move thatremained was to drop the
**BREAKING**token. Four changesets took it (#8140, #11925,#12034, #12104). Read individually each was reasonable; read together they are the finding, and
this PR records them in the ADR as a counter-example, never as precedent.
The sixth category
not-required (type-surface-only PATH#SYMBOL), admitted on four predicates, all four,pinned by name and not by count (
TYPE_SURFACE_PREDICATES):publishedunpublished, off the same workspace manifestsno-spec-diffpackages/spec/**pathno-metadata-surface-diffcontracts/**entry, no object definitionnarrowed-from-erasedany/unknown/ unannotated at the MERGE BASE and concrete at HEADPredicate 4 is what makes this a narrowing rather than a hole
This is the only category exempt from the
no-migration-prescriptionrefusal. The exemptionis necessary — without it the gate still refuses the exact class the category was created for —
and it is also the only place in the vocabulary where something that detector refuses today
becomes claimable. Predicate 4 pays for it.
Predicates 1-3 alone were measured not to separate this gate's founding case: #6048 and the
#12104 shipping half come out identical on all three. The prescription refusal is the only guard
holding #6048 out (
verifyRuntimeInterfaceOnlyaccepts its symbol without complaint), soexempting 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
ActorUseris a concretely typed exported interface at base that lost amember, while this class starts from
any.The same case, after
The reproduction script above, re-run against this branch: every other disposition is refused
exactly as before, and
type-surface-only PATH#SYMBOLis ADMITTED. Nothing else widened.Machinery, and one thing the ruling did not have measured
Reuses
parseSymbolRef/exportedTypeDeclarationas directed. No TS-aware diff reader(predicate A was not adopted and is not present).
One assumption in the dispatch order came out partly falsified, and it is worth a reviewer's
eye:
exportedTypeDeclarationmatchesexport interface|type|class|enumand nothing else, andthree of the four live instances narrow a METHOD RETURN, not an exported type.
b15d260d1is
queryDataset: async (payload) =becomingqueryDataset: async (payload): Promise< AnalyticsResult > =,with no exported type declaration anywhere in the diff. A category unable to read that shape
would be born unable to serve its own population — the #13080 defect one layer down. So this PR
adds
memberReturnAnnotation, a sibling ofexportedTypeDeclaration: it reads one namedsymbol, in one file, at one rev, and looks at no diff. Comment and string spans are masked
through the house scanner (
js-comment-mask.mjs), never a private stripper.Coverage — 237 to 292 self-test assertions
so the case is attributable (
TSO-R1..TSO-R4);TSO-G1) — whose body carries a real framed rewrite table, so itis also the assertion that the prescription exemption works;
TSO-R4carries the identical body and must be refusedby predicate 4, not by the prescription refusal (a negative assertion says so);
TSO-R5shows
no-migration-prescriptionon that same body still refuses;TSO-R6(still erased at HEAD) andTSO-R7(a path this diff creates narrowed nothing — thedoor a decoy symbol would otherwise walk through);
TSO-N1) plus a pin that the exported names are theones
verifyTypeSurfaceOnlyactually evaluates (TSO-N2b) — a name that is exported and neverevaluated is a predicate the ADR promises and the gate does not check;
TSO-6048breadspackages/runtime/src/security/actor-user.tsout of this tree at HEAD and asserts predicate 4reads the live
ActorUseras CONCRETE (it cannot diverge from the tree, because it is thetree; if the file moves, that is a red saying to re-anchor, never a skip);
TSO-6048cdrivesthe full shipping
scan()over a two-commit reconstruction of the feat(runtime)!: 退役 ctx.user 的 roles 别名,positions 成为唯一拼法 (#6011) #6048 diff claiming thiscategory, and requires the refusal to name predicate 4;
Promisethat is onlypart of a type must not unwrap, because a greedy match there is wrong in the admitting
direction.
Reverse verification
Each leg mutated, proved on disk and at the referent the instrument actually reads, measured,
restored, and the restore proved by blob hash:
git show HEAD:ADRreally lacks it)category documented nowhere: type-surface-onlytype-surface-onlyremoved fromCATEGORIES, ADR keptcategory documented but not accepted: type-surface-onlyTSO-G1firstTSO-6048cgoes GREEN, i.e. the founding case becomes claimable. This is the measured regression the ruling priced, and the pin catches itTSO-N1andTSO-N2bboth fireablation came out exit 0. The gate reads the ADR from git at HEAD, not from the working
tree, so an on-disk mutation was invisible to it. Proving the mutation landed on disk was
necessary and not sufficient — it had to land at the referent the instrument reads.
Verification
Union re-run on the final commit
e355e1eca, after the last edit:node scripts/check-adr-0087-registration.mjs --self-test—292 assertions over real temp git repos (real scan()/assertInputs() path)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom this diff (20 families) — all green, plus both convention-triggered obligations for editing
a gate script (
scripts/pm/bare-root-worklist.mjs --self-test,pnpm check:pm-dispatch-gates—
1017 cases pass)check-empty-changeset.mjs --self-test(118),check-changeset-no-major.mjs --self-test(116),js-comment-mask.mjs --self-test(35)pnpm lint— repo-wide ESLint, exit 0 (not narrowed)node scripts/check-nul-bytes.mjs— clean; both edited files also scanned directly for rawcontrol bytes
node scripts/check-test-completeness.mjs— NOT MEASURED, by the gate's own instruction: itgrades a saved
turbo run testlog and printsPREREQUISITE NOT METwith no argument, which isthe branch the derived family invokes. Recorded rather than reported as a failure.
Deliberately out of scope
.changeset/is touched here.declare-search-clone-response-contractsbumps
@objectstack/specand addspackages/spec/src/api/protocol.zod.ts, so it failspredicates 2 and 3 outright. Recorded in the ADR so the backfill does not discover it by
going red.
ADR), which is the repo's
skip-changesetcase.Follow-up — the merge-queue ejection, fixed on
fe829beaaThe queue ejected this PR on a gate in a package this diff does not touch:
packages/client/src/envelope-caller-census.test.tswent red withexpected 21 to be 19.Cause. That census walks the whole workspace for four dispatcher-served SDK spellings and
masks comments only (
scripts/js-comment-mask.mjs) — never string literals. Thetype-surface-onlyfixture's changeset body is a string literal, and its migration tabledemonstrated a call on
client.analytics.query: two occurrences, counted as real SDK call sites.Measured with the repo's own
maskComments, over every file this PR changes:origin/mainanalytics.queryanalytics.metaanalytics.explainautomation.triggerdocs/adr/0087-metadata-protocol-upgrade-contract.mdis not scanned by that census at all — itsextension is outside the census's own extension set. The two comment mentions this PR adds are
masked correctly and contribute nothing, which is what isolates the mechanism to literals.
Fix — the fixture now demonstrates its OWN subject.
TSO_REFnamesqueryDatasetandTSO_BASE_CLIENTdeclaresqueryDataset, so a table rewritinganalytics.querywas prescribinga migration this fixture's diff does not ship. The table now rewrites
queryDataset, and thedirection is the honest one for it: that route is served BARE by
@objectstack/rest, so there isno envelope —
.data.rowsis a spellingPromise< any >used to permit and the new annotationrefuses, a prescription the COMPILER delivers, which is exactly what this category is for. The
fixture keeps a genuine FROM/TO migration table, which is what makes
TSO-G1an assertion aboutthe prescription exemption rather than a case that passes for another reason.
⛔ Not touched:
packages/client/**(not the census, not its ledger, not its expectations), theADR half, and the census's coverage — nothing was masked, skipped or exempted. Registering
fixture prose as call sites would put false data in a ledger whose whole purpose is to be true.
Verification on
fe829beaa— the union re-run after the last edit. The branch also carries aclean merge of
origin/main, so the generation CI now builds is the one the queue rebuilds:pnpm --filter @objectstack/client exec vitest run src/envelope-caller-census.test.ts—before:
Test Files 1 failed (1)/Tests 3 failed | 10 passed (13),AssertionError: expected 21 to be 19;after:
Test Files 1 passed (1)/Tests 13 passed (13)pnpm check:adr-0087-registration—292 assertions over real temp git repos (real scan()/assertInputs() path), shipping run cleanpnpm check:ratchet-remedy-authority—176 scripts swept (scripts/*.{mjs,mts}), self-test OK. Run explicitly: it builds its population at runtime from a directory read and declares no path literal, so no path derivation names itnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack— 20 families, plus both convention-triggered obligations for editing a gate script (scripts/pm/bare-root-worklist.mjs --self-test,pnpm check:pm-dispatch-gates—1048 cases pass) — all greenpnpm lint— repo-wide ESLint, exit 0 (not narrowed)pnpm check:nul-bytes— clean; the edited file also scanned directly for raw control bytesnode scripts/check-test-completeness.mjs— NOT MEASURED, as above: it grades a saved test log that the derived family hands it no argument for.pnpm --filter @objectstack/lint run check:doc-formula-expressionsfirst printedPREREQUISITE NOT MET(its package was unbuilt), and is green after building it — a NOT MEASURED reading is recorded as such, never as a passNo changeset, unchanged reasoning: the follow-up commit touches only
scripts/check-adr-0087-registration.mjs, the root package is private with nofilesarray, andno workspace package's
filesarray carriesscripts. Labels re-read live after the push:documentation,size/l,skip-changeset— the exemption label is still on.Filed, not fixed here — #13874: the general shape, that any gate quoting example code inside
an author-facing message string is indistinguishable from a call site to this census. Bare and
unassigned; routing and priority are triage's.
Follow-up session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Generated by Claude Code