feat(spec): ship a per-release section in spec-changes.json, verified against both tarballs - #18889
Conversation
`spec-changes.json` is keyed to the protocol major while this repo's launch window ships breaking changes in minors, so a consumer crossing 17.3.0 -> 17.4.0 reads `added: 0, removed: 0` over a delta of 225 added and 51 removed exports. Adds the `release` section (from -> to at package-version resolution), generated at publish time only so the committed copy stays the deterministic registry projection, plus the correctness gate that recomputes the delta from the two tarballs and fails the release on a mismatch, naming the disagreeing exports. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
`protocolVersionGap` answers the major-resolution question and is null for an app on `^17` running spec 17.4.0 — correct, and silent about a release that narrowed accept-sets under the launch-window convention. `specReleaseChanges` reads the installed artifact's own per-release section (ADR-0087 D4) so a CI job can ask what moved in the release it has. A sibling key, not a widening of the one above: one key, one question. Adds the consumer docs section, the changeset, and the unit tests for both the fold and the reader. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…ports Seven new declarations reach the root entry — the per-release schema, its surface-entry schema, the fold and their types. No export is removed or narrowed. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…uard The gate exports `verifyRelease` so its self-test drives the same function the release lane calls; a `scripts/**` module that exports a binding and dispatches at the top level runs inside whoever imports it (`pnpm check:entry-guard`). Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c38608877ccc08fc0e719a6fcd7e9dcc312bec56 && git checkout c38608877ccc08fc0e719a6fcd7e9dcc312bec56
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2767af8e8354511f9c82ce402b147ad12c512819 2733f3f2495146867eba94dff1d0fce6b7dc14df && git checkout -B drift-repro 2767af8e8354511f9c82ce402b147ad12c512819 && git merge --no-ff 2733f3f2495146867eba94dff1d0fce6b7dc14df
node scripts/docs-audit/affected-docs.mjs --json 2767af8e8354511f9c82ce402b147ad12c512819
|
…ssify the release-delta reader
Two CI reds at the pushed head, both this branch's.
`scripts/check-release-spec-changes.mjs` seeded its self-test tree from
`fs.realpathSync('/tmp')`. The exposed-scratch-dir sweep in
`scripts/pm/dispatch-gates.mjs` resolves every `mkdtempSync`/`mkdirSync` base
statically to prove no gate writes its scratch tree into the repo, and reports a
base it cannot read as UNRESOLVED rather than assuming it is fine — so the sweep
reddened on a call it does not model. `tmpdir()` is the spelling every other
gate in `scripts/` uses, it is what the sweep reads as outside the tree, and it
honours TMPDIR/RUNNER_TEMP where the literal did not.
`packages/cli/test/validate-build-gate-parity.test.ts` holds a CLOSED roster:
every bare identifier `compile.ts` or `validate.ts` calls must land in exactly
one of its three ledgers. `readSpecReleaseChanges` arrived in `validate.ts`
unclassified. It goes in NOT_A_GATE, with the reason: it reads the installed
`@objectstack/spec`'s own published per-release delta and takes nothing from the
stack, so it reaches no verdict and can refuse nothing. The row states the
distinction from `checkProtocolVersionGap` next to it, which does judge the
input and is therefore a shared gate.
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…r-release-spec-changes
Contract reviewServed-tier: 100/100 ⭐ Tier verified by the seat from the reviewing round's harness-stamped per-message served-model field: 100 of 100 at tier, 0 off-tier. The round correctly declined to count its own. ① Derived judgments1. The correctness gate fails DIAGNOSABLY — reproduced on real artifacts, ⛔ not taken on report. This was the seat's added constraint (ruling clause 2 requires the gate; the seat required that a mismatch name what disagrees and in which direction, because a gate that wedges a release without saying why is worse than the defect it guards). The round unpacked the published
⭐ The dev's own reported ablation reproduced verbatim — same export, same direction wording. Lit control: the tarballs as published (17.4.0 ships no section) → exit 1 with the 「one is owed」 diagnosis. Dark controls: no args → exit 2 + usage; a nonexistent path → exit 2 「nothing was checked」. ⇒ not a bare non-zero anywhere. 2. Ships in the tarball AND the committed copy stays deterministic — both hold, which is the pair clause 1 buys. 3. Generation really is publish-time only. Established by sweeping every workspace 4. The card's figures reproduced, and the one that did NOT is checked. 17.3.0 → 17.4.0: ② Semver levelminor on ③ Boundary flags — PASS, and three of these are worth the next reader's time
⭐ The
|
…r-release-spec-changes
…rted The per-release section carries ADR-0087 D4's four arrays, and the boundary they draw — an id that disappeared from the published chain is deliberately NOT reported — lived only in `composeReleaseChanges`'s JSDoc and the PR body. A consumer reads `release.converted: []` as "no conversion change" while an id really did leave the chain between the two releases. Say it where a consumer reads it: the shipped manifest's `$comment` and the upgrading page's "what it does not tell you" callout, with the two-manifest comparison that does answer the question. Documentation of an existing boundary — no fifth array, no shape change, and the generator and the release gate are untouched. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
✅ 达档合约复核 PASS —— 合并增量,点名落地 head
|
| 断言 | 复核的取法 |
|---|---|
| 合并没吞任何一侧 | 主侧 39 路径 / 分支侧 18 路径,comm -12 交集为空(亮对照:两个重叠表命中 b)⇒ 冲突结构上不可能。57 个路径的 blob 逐个对上,0 处不符 |
| 没有手工解决 | git diff-tree -c 951f75c1f7a 空。⭐ 亮对照是在合成仓里造的 —— 因为本仓 squash 合并,origin/main 最近 40 个合并同样给 0,真仓的 0 证明不了这条 |
| 两条 MIXED 免路由路径 | dropped-refinements.baseline.json 与 migrations/registry.ts 在 base / branch / main / merge / head 五个点字节相同,两侧都没动 |
| 生成物无漂移 | 本地真 build 后 check:generated —— 15 个产物全部最新。⭐ 这比 PASS 强:PASS 那条取自 CI |
| OSV 解药确实由 base 到达 | git merge-base --is-ancestor 5e0a1b9e023 951f75c1f7a exit 0(亮 89c6ec52b56 exit 0;暗 873e0e8e270 exit 1);head 锁文件解析 devalue@5.9.2 |
| 386 这个数 | 重取,⛔ 不转抄 —— 真 npm pack 拉published 17.3.0,head 树 pnpm pack,闸门对两个 tarball:exit 0,386 added, 302 removed。每次跑都 trap … EXIT INT TERM 还原,前后 hash 相等,git status --porcelain 空 |
| head 上的 CI | 43 行 = 39 success + 4 skipped,0 失败 0 在跑,每行 head_sha 都等于落地 head。必需上下文从 ruleset 自己读(12119582,strict=false):七条全 success |
⭐ 复核还把撤回配方拿真产物跑了:published 17.3.0 对 head 清单,conversions 68 → 67,撤回的恰是 field-required-notnull-explicit;migrations 87 → 87。⇒ 文档里那条配方真的能回答它声称能回答的问题,不是一句安慰话。
⛔ 本席不采信复核的一条,并已当场证伪
复核 ③.5 写「正文尚未带 302 removed」。假。取正文实测:302 在正文中出现 1 次,就在验证行上,原文 ✓ release 17.3.0 → 17.4.0 verified against both tarballs: 386 added, 302 removed, 0 converted, 0 migrated. ⇒ 该条作废;同一条的前半(374 → 386 是本席的更正且更正正确)复核测对了,保留。
⭐ 复核没看见、本席自己查出并已修的一个缺陷
本席上一次写那条更正时犯了 markdown 语境错:
- 更正按语被写进了 code fence 里面(围栏 68→73,按语在 72)—— 它会以等宽字面量渲染,读起来像是闸门自己打印的一部分;
- 更正把围栏内那行改成了
**386** added。闸门从来没打印过星号。 围栏是逐字引用语境,往里塞**就是把工具输出的引文改掉了。
两条都已修:围栏内那行还原成闸门逐字打印的样子,按语移到围栏外面当散文。守卫脚本核过:围栏数 4 且成对、Clause-②: 行首仍恰好 1 条、302 removed 仍恰好 1 次、未新增尖括号。⇒ 这与本席此前踩过的 引用时间戳的 WAS 记号落进反引号那次是同一族错(引用语境里的标记不是标记)。
⭐ 顺带测到一条此前明说未测的事:正文重复 PATCH 不累积页脚 —— 本次写回字节数 8782,与本地算出的完全相等,平台没有再追加那 58 字节。此前只能说「只 PATCH 过一次,故未测」,现在测了。
③.3 已另立卡,⛔ 不挡本卡
复核点名 aggregate 切片在 major 分辨率上标注误导、且本 PR 把它推进了 npm tarball 的可达面(AGENTS.md 规则 4「机器可读面不得说谎」)。本席已立 #18978,并同意它不挡:裁决第 1 条要的就是发布期清单进 tarball 而这正是它的形状;since 在其自述分辨率上为真;上一版 tarball 带的是 aggregate.added: [],严格更差,正是本卡要终结的读数。
以下为达档复核记录原文,本席逐字采纳,⛔ 未编辑一字。
Contract review
Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1737a24b510f1086af658c1351ad53ec85e1be7e
Merge-delta review only: everything the PASS record 5727299294 judged at 4f1724d33b9 is closed and not reopened here. Scope = 4f1724d33b9 → 1737a24b510f1086af658c1351ad53ec85e1be7e, i.e. merge commit 951f75c1f7a (parents exactly 4f1724d33b9 and 89c6ec52b56, git rev-list --parents) plus one docs commit. Clone deepened by 200 before any negative was trusted; merge-base 625db0e8531 is 653 commits deep and is NOT in .git/shallow (lit: the first shallow entry IS listed there).
① Derived judgments
1. The merge dropped neither side — measured per path, both directions, with hitting controls.
- Main-side
git diff --name-only 4f1724d33b9 951f75c1f7a= 39 paths; branch-sidegit diff --name-only 89c6ec52b56 951f75c1f7a= 18 paths.comm -12intersection = EMPTY (lit control: two overlapping lists hitb). ⇒ no path was touched on both sides, so a conflict was structurally impossible — 「zero conflicts」 confirmed from the tree, not the report. - Every one of the 39 main-side paths carries main's blob in the merge and every one of the 18 branch-side paths carries the branch's blob: 0 mismatches of 57 (lit control: the first main-side path vs the branch parent reads ABSENT ≠ merge blob).
git diff-tree -c 951f75c1f7a= EMPTY, i.e. no path differs from all parents — no hand resolution happened anywhere. Lit control in a synthetic repo: a hand-resolved conflict yields 1 combined-diff path; dark control: a clean two-sided merge yields 0. (Note:origin/main's last 40 merges yield 0 too — the repo squash-merges — which is why the synthetic control was needed.)- The two MIXED, hand-resolve-only paths carry the base bytes unchanged on all five points:
packages/spec/dropped-refinements.baseline.json=916e598cb16andpackages/spec/src/migrations/registry.ts=09ff2453556at base, branch, main, merge and head. Neither side moved them; nothing was resolved and nothing needed to be. - Generated drift:
pnpm --filter @objectstack/spec check:generatedrun locally at the head after a realpnpm --filter @objectstack/spec build(both underos-verify-lock.sh, VERDICT command-exit 0, held 175s / 75s, no queue-timeout): 「✓ All 15 generated artifacts are up to date」 includingcheck:spec-changes,check:migration-registry,check:api-surface,check:export-origins. This is stronger than the PASS, which tookcheck:generatedfrom CI. - The devalue remedy really arrived through the base:
git merge-base --is-ancestor 5e0a1b9e023 951f75c1f7aexit 0 (lit:89c6ec52b56exit 0; dark: the newer main tip873e0e8e270exit 1); head'spnpm-workspace.yamlcarries'devalue@<6.0.0': '^5.9.2'and the lockfile resolvesdevalue@5.9.2.
2. Gate and generator untouched — confirmed with a hitting control. git diff 4f1724d33b9 1737a24b510 -- scripts/check-release-spec-changes.mjs packages/spec/src/migrations/spec-changes.ts packages/cli/src/utils/spec-release-changes.ts scripts/release-spec-changes.sh .github/workflows/release.yml = 0 bytes (lit: the same instrument against 89c6ec52b56 on the gate file alone = 690 lines). origin/main moved none of those paths either (git diff --stat 625db0e8531 89c6ec52b56 over them + build-spec-changes.ts = empty; lit: main did move scripts/check-adr-0087-registration.mjs, 342+/21−). The only generator change is packages/spec/scripts/build-spec-changes.ts 6+/1−, and I read the hunk: every changed line is inside the $comment: string-literal concatenation ('tool derive from this same data. ' + … 'withdrawn"; a withdrawal is visible only by comparing two published manifests.'). check-release-spec-changes.mjs --self-test: 15 batteries pass, exit 0. SpecReleaseChangesSchema at head names exactly fromVersion, toVersion, added, converted, migrated, removed — no fifth array, and the generated section's keys read back as exactly those six.
3. The honesty fix — read as a consumer, it does prevent the misreading, not merely mention it. The shipped $comment now says, verbatim: 「converted: [] means "this release registered none", never "none was withdrawn"; a withdrawal is visible only by comparing two published manifests.」 That is the exact sentence a reader of release.converted: [] needs, in the first key of the file they open. The upgrading page's warn callout repeats it and adds the working recipe — compare .aggregate.converted[].conversionId / .aggregate.migrated[].migrationId across the two installed manifests. I ran that recipe on the real published 17.3.0 tarball against the head's committed manifest: conversions 68 → 67, withdrawn = exactly field-required-notnull-explicit, new = none; migrations 87 → 87, none either way (lit: the same reader finds 68 on the previous manifest). So the recipe answers the very question the PASS flagged. Two limits, stated not hidden: the release object carries no $comment of its own, so a machine that jqs straight to .release never sees the text — no comment can stop that, and the docs page is the second carrier; and the text covers withdrawals only (see ③.3).
4. The numbers at the new head — re-taken, not transcribed. npm pack @objectstack/spec@17.3.0 (real registry, version read back 17.3.0, no release key, aggregate.added/removed 0/0), unpacked, then at the head worktree tsx scripts/build-spec-changes.ts --previous-package …: release = { fromVersion: '17.3.0', toVersion: '17.4.0', added: 386, removed: 302, converted: 0, migrated: 0 }. Then pnpm pack of the head tree and the gate against both unpacked tarballs: exit 0, 「✓ release 17.3.0 → 17.4.0 verified against both tarballs: 386 added, 302 removed, 0 converted, 0 migrated.」 Lit control: the gate with the published 17.3.0 artifact as --published → exit 1, 「ships no release section, but one is owed」. Dark control: --check on the committed copy → exit 0 「up to date」. Every generator run restored packages/spec/spec-changes.json under trap … EXIT INT TERM, hash proven equal before/after (9dbc98682bfc…), git status --porcelain empty. ⇒ 386 is right; the PR body's 「374 added」 is stale and the seat's correction to 386 is correct. The body also does not yet carry 302 removed.
5. CI at the head, pinned to the sha. GET /commits/1737a24b510…/check-runs: 43 rows, 39 success + 4 skipped, 0 failures, 0 in progress, every row's head_sha = 1737a24b510f1086af658c1351ad53ec85e1be7e. (My earlier PR-level read at ~09:30Z showed 36 = 34 + 2; the 7 extra rows are second runs of the pull_request-edit-triggered jobs after the seat's body edit — Auto Label, Check PR Size, Check Changeset, the three claim/single-writer guards, Part-of — all success or skipped.) The four skipped: Auto Label (2nd run), Check PR Size (2nd run), Console Pin Gate, Packed-tarball smoke (opt-in). Required contexts read from the ruleset itself, GET /rules/branches/main → ruleset 12119582, strict=false: exactly the seven the round names — Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — all success. Validate Package Dependencies is success (id 105535698035) and is NOT a required context.
6. Carrier state. node scripts/pm/check-clause2-carriers.mjs --pair 18889 (read-only, 4 GETs): exit 0, declaration Clause-②: yes readable via correction 5725116531, both carriers agree, pair.1.head-sha = the head above. Governed-surface hits in the PR's 19-file list: 0 (lit: a list containing AGENTS.md hits 1). The PR's file list from the API matches the local 89c6ec52b56..head list exactly (19 = the branch's 18 + packages/spec/spec-changes.json, which joined in the docs commit).
② Semver level
minor on @objectstack/spec and @objectstack/cli, unchanged by the delta. The changeset .changeset/17080-per-release-spec-changes.md is byte-identical 4f1724d33b9..head (0-byte diff) and still declares both minor with Clause-②: yes (widening) line-anchored. The docs commit adds no key, no export and no schema change: the committed spec-changes.json changes only in its $comment string value, and check:spec-changes is green against it (deterministic, dark control exit 0). Main's side of the merge carries its own already-landed changesets (18102, 18406, client session envelope) — those are main's declarations, not this PR's.
③ Boundary flags
- Clause ② does not move.
Clause-②: yes (widening)stands for the PR's own additive published surface (optionalreleasesection,specReleaseChangeskey, 7 new root exports) — 「扩大公开面」, ⛔ not theSKILL.md:515pull-back case. The delta itself adds nothing to that:check-widening-tells --declaration noover the docs commit alone (951f75c1f7a..head) = 0 tells, exit 0; over the whole delta (4f1724d33b9..head) the single tell ispackages/spec/src/ui/view.zod.ts:1777(styleonListMapConfigSchema), which is main's feat(spec): declarestyleonListMapConfigSchemaand pointobject-map.mapback at it #18904 arriving through the base (1aa5026e30a, in the 39 main-side paths) — main's side is not this PR widening anything. Lit: the whole PR (89c6ec52b56..head) shows 14 tells, all the PR's ownreleaseschema and exports. - The PR now edits the committed
spec-changes.json(19 files, not the 18 the PASS counted). Only the$commentvalue moved; the generator and the committed copy agree; no shape change. Recorded so the next reader is not surprised by the extra path; not a defect. - Honesty gap 2(b) — I agree it is a reach change, not a content change, and I agree it does not block; it must be filed. Measured: the aggregate line
surfaceDiff = PREV_SURFACE ? diffSurfaces(PREV_SURFACE) : {}andcomposeSpecChanges(MIGRATION_SUPPORT_FLOOR, PROTOCOL_MAJOR, surfaceDiff)are identical at merge-base (lines 91/100) and head (189/198), andPREV_SURFACEis set whenever--previous-packageis. At merge-base the lane already ran the generator with--previous-surfacepost-publish (release.yml:920), so the Release asset already carried this. What is new is that--prepareruns beforechangeset publishand writes into the tree that is packed: in my packed head artifactaggregate.added= 386 entries labelledsince: 17andaggregate.removed= 302 labelledremovedIn: 17, name-for-name equal torelease.added/removed, underfrom: 10, to: 17, whileperMajor[16→17]stays 0/0. Why not blocking: ruling clause 1 orders the publish-time manifest shipped in the tarball, and this IS its shape;sinceis documented as 「The protocol major that added it」 (schema line 38), so the label is true at its stated resolution — what misleads is the completeness the 10→17 record implies, a pre-existing D4 design; the gatedreleasesection precedes it and the docs route a consumer to it; and the previous tarball carriedaggregate.added: [], the strictly worse reading this card exists to end. Why it must be filed: AGENTS.md rule 4 「Machine-readable surfaces must not lie」 now reachesnode_modules, and the$commenthonesty fix says nothing about the aggregate slice where it could have in one sentence. Remedy candidates for that card: omit the surface diff fromaggregateat publish time (keep it only inrelease), or state the slice's true bounds in data. - The withdrawal recipe is verified true on real artifacts (68 → 67,
field-required-notnull-explicit; migrations 87 → 87) — evidence for this PR's change, no card. - PR body drift the seat owns: 「374 added」 → 386;
302 removedabsent from the body's verification line. Nothing else in the body, the PASS record or the merge round's report5727978261was falsified by my measurements — every figure I re-took (39 files, 18 branch-side, empty conflict set, empty gate diff, 6+/1− inside$comment, 15 artifacts up to date, 386/302/0/0, 7 required contexts green,Validate Package Dependenciessuccess) reproduced.
NOT MEASURED
- The release lane itself (
release.yml--prepare→--verify→ publish) — no seat may trigger it; its local half was reproduced (generate → pack → gate) instead. packages/cliunit/integration suites and typecheck at the new head, locally — the delta's branch-side touches no cli file (docs page, generator$comment, committed manifest), so my method owed none; taken from CITest Core (1..6/6)andType Check · workspacesuccess at the head.- The repo-wide gate farm locally (the 125 UNRUN families the round declared) —
Lint & Repo Gatessuccess at the head is CI's reading, not mine; locally I rancheck:generated(15), the release-gate self-test (15 batteries),check-widening-tellsandcheck-clause2-carriers. - GitHub Actions job logs — not read; every CI reading is from the check-runs API pinned to the sha and the ruleset API.
- Behaviour of the merge-queue build against a main that has since moved (
873e0e8e270is already ahead of the PR base; ruleset strict=false) — the queue's own build is what measures that. objectstack-ai/ats— out of scope, not reached.- Scratch left at
/tmp/claude-0/-home-user/d31c56ec-952d-5a16-945f-6e430b9a6d34/scratchpad/pr-18889/(worktreewtat the head,prev/= unpacked 17.3.0,packed/= my pack, logs); the worktree is registered in/home/user/objectstack/.gitand can be removed withgit worktree remove.
Implemented-by: claude/issue-17080-per-release-spec-changes
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho
VERDICT: PASS
Generated by Claude Code
…r-release-spec-changes
…w exports `main` grew `packages/spec/api-surface-declarations/**` after this branch was enqueued, so the queue build's `check:api-surface-declarations` saw this PR's 7 root exports as undeclared text. Regenerated with the remedy the gate prints; no hand edit to a shard. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Contract reviewServed-tier: Merge-delta review only. Everything the PASS at ① Derived judgments — each claim of the round, re-measured1. Merge was mechanical; no hand resolution; the MIXED paths arrived from main untouched — TRUE, but the round's "clean" needs one correction of instrument.
2. 3. Regeneration touched
4 / 5. 6. Release gate re-derived at this head, 386/302/0/0 unchanged — NOT MEASURED live; DERIVED to be unmovable. The gate reads only the two tarballs' 7. Both merge sides survived — TRUE. 17/17 shards head vs main tip (control: 0 at ⭐ The dispute — the ROUND is right, the seat's dispatch was wrong for this window. ② Clause ② for the deltaThe delta neither widens nor narrows; the PR's standing ③ Does the delta change any conclusion of the two prior PASSes? No.Every artifact those records judged is either byte-identical across the delta (gate script, generator, Boundary flags — none blocks
NOT MEASURED
Implemented-by: VERDICT: PASS — landing head Generated by Claude Code |
✅ 增量复核 PASS —— 落地 head
|
| 前提 | 读数 |
|---|---|
| ① 点名落地 head 的达档 PASS | 本记录,点名 2733f3f2495 |
② check-clause2-carriers --pair 18889 |
见下方本席重取 |
| ③ 受管面 | check-governed-merges.mjs --test 覆盖 20 条有效路径 = exit 0「NOT governed」;亮对照加 AGENTS.md = exit 3 |
head 上 CI:47 个 check-run = 40 success + 7 skipped,0 失败 0 在跑,七条必需上下文全 success。
条款②:不动,仍是 yes (widening)
重生成的分片只是把 PR 早已新增、且前两条记录已判过的 7 个导出的 .d.ts 文本记下来;不新增导出、不新增键、不新增闭集成员。⇒ 已发布产物追上一个已声明的面,⛔ 不是 SKILL.md:515 的拉回情形。
⛔ 本席没有回避的一条:那句「没手改分片」不是复核自测的
复核明说它自己的重生成还卡在锁队列里,这一半取自 CI 在该 sha 上的 Type Check · consumer gates(它本身就是「重新生成再比对」)。本席接受这条取法并写在明处:那正是能分辨「真重生成」与「手打得像重生成」的仪器,而它在 2733f3f2495 上是 success。⛔ 本席不把它说成复核亲测。
记录原文另贴一条,⛔ 原因写在明处
达档复核记录逐字发在紧接本评论之后的那一条。之所以拆成两条:该记录正文里带它自己读到的裸时间戳(它在自己那次取数时读到的时刻),与本评论的 {{NOW}} 同处一体会被 post-stamped 判为 MIXED 并拒写——那条拒绝是对的。
⛔ 本席不会为了让工具放行去改复核记录一个字:协议是逐字采纳或整条作废,没有第三种。⇒ 拆条,本席这条带自己的时钟,记录那条原样落地。
Generated by Claude Code
Contract reviewServed-tier: Merge-delta review only. Everything the PASS at ① Derived judgments — each claim of the round, re-measured1. Merge was mechanical; no hand resolution; the MIXED paths arrived from main untouched — TRUE, with one correction of instrument.
2. 3. Regeneration touched
4. 5. 6. Release gate at this head — TRUE, re-derived from real artifacts, ⛔ not transcribed. 7. Both merge sides survived — TRUE. 17/17 shards head vs main tip (control: 0 at ⭐ The dispute — the ROUND is right; the seat's dispatch was wrong for this window. ② Clause ② for the deltaThe delta neither widens nor narrows; the standing ③ Does the delta change any conclusion of the two prior PASSes? No.Every artifact those records judged is byte-identical across the delta (gate script, generator, Boundary flags — none blocks
NOT MEASURED
Implemented-by: VERDICT: PASS — landing head Generated by Claude Code |
📌 复核记录升级 —— 同一裁断、更强的证据;并更正本席自己上一条记录里的一句话
裁断没变:PASS,仍点名落地 head ⛔ 本席上一条记录(
|
| 此前 | 现在 |
|---|---|
check:generated = 16 —— 取自施工轮 |
亲测:「Checking 16 generated artifacts」「✓ All 16 generated artifacts are up to date」exit 0 |
check:api-surface-declarations —— 取自施工轮 |
亲测:「declaration text unchanged ✓ (17 entry points, 5343 declarations)」exit 0 |
| 发布闸门 386/302 —— 推导为不可动 | 亲测,真产物:真 npm pack 拉 published 17.3.0(shasum 1ca896ffbc05…)+ pnpm pack 本树 ⇒ exit 0「386 added, 302 removed, 0 converted, 0 migrated」。亮对照:拿 published 17.3.0 当 --published ⇒ exit 1「ships no release section, but one is owed」;暗对照:还原后 --check ⇒ exit 0 |
⭐ 最要紧的一条:「没手改分片」现在是复核自己的仪器证的。 它在 head 的独立工作树里做了一次真构建,再跑 gen:api-surface-declarations 重新生成全部 17 个分片 ⇒ git status --porcelain 0 行、git diff --stat 0 行。
⇒ 重生成是对已提交树的逐字空操作。手打出来的分片活不过这一关。 这比 CI 那条更强,因为它是在本席能复核的工具下、带前后哈希与 porcelain 证据跑出来的。
spec-changes.json 的变异/还原仍在 trap … EXIT INT TERM 下,前后哈希相等(9dbc98682bfc…),porcelain 空。
不变的部分
条款② 仍是 yes (widening),delta 既不放宽也不收窄;两侧合并逐字存活;⛔ 前两条 PASS 的任何结论都没被这次 delta 改动。
⭐ 以及那条方法学升级仍然成立,值得下一个人记住:本次合并两侧路径集并不互斥,所以 git diff-tree -c 为空不足以证「无手工解决」;要用 merge-tree --write-tree 的树哈希等值,并在一个没注册驱动的裸克隆里复现。
本席的收尾
PR 已重新入队 —— added_to_merge_queue 事件,2026-09-18T11:16:13Z(第二次;第一次在 10:07:20Z 被那道新闸踢出)。入队前本席按自己的教训先看了 main 的新落地:#18984、#18994 都不碰声明分片、产物注册表、api-surface/ 或 export-origins/ ⇒ 上次那个失败模式不会从这两笔再来。
复核点名要本席清理的残留(独立工作树、两个 refs/pm-review/*、探针裸库与 scratch),本席清。
Generated by Claude Code
…napshot Resolves 13 modify/delete conflicts under packages/spec/api-surface-declarations/. Every conflict has the same shape: this branch deletes the file (no stage 2), main regenerated it (stage 3). Retiring that directory is the revert's whole purpose, so each conflict resolves to the delete. All 17 shards are gone from the merged tree -- the 4 main did not touch auto-resolved to delete already. The one other overlapping path, scripts/pm/dispatch-gates.mjs, auto-merged: main's hunk sits about 1600 lines from the reverted one. Verified on the merged tree rather than assumed: - no code, script, workflow, gitattributes or package.json entry references api-surface-declarations in any spelling; the only three mentions left are historical prose in .changeset release notes (17108, 18991, 19085), reported separately and deliberately not edited here. - of the 31 paths the reverted commit touched, none still carries a line that commit added; the four that differ from its parent are later, unrelated work main landed (lint.yml keeps #18889's step; check-published-files, dispatch-gates and regen-artifacts carry post-revert commits). - api-surface-signatures.json is back with its 27 hashes and, built from these merged sources, check:api-surface reports the public API surface and factory signatures unchanged -- so the restored pin is correct, not merely present. - check:generated reports all 15 artifacts up to date; main's count is 16, and 16 is what #18971 made it when it registered check:api-surface-declarations. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-Authored-By: Claude <noreply@anthropic.com>
…E on objectstack-ai#18373) (objectstack-ai#18946) Fixes objectstack-ai#18373 Clause-②: no `skip-changeset` — measured, not asserted; see Verification below. Executes the maintainer ruling of 2026-09-18 on this card (batch objectstack-ai#153 item 3, **letter E**): retire `check-type-source-resolution`. This PR is EXECUTION — it does not re-argue A/B/C/D. ## What the ruling ordered, and where each piece landed | the ruling's words | landed as | |:--|:--| | delete `scripts/check-type-source-resolution.mjs` and its self-test | file deleted. Its "self-test" was the file's OWN `--self-test` dispatch, not a separate file, so it went with it (`git ls-files` matched exactly one path for the name). | | `KNOWN_DIST_RESOLVED_TYPE_IMPORTS` with it | that registry lived inside the deleted file; the identifier now has **0** occurrences anywhere in the tree. | | the `check:type-source-resolution` entry in the root `package.json` | removed (one line). | | the 「Type-source resolution gate」 step in `.github/workflows/lint.yml` | step removed, together with the 23-line comment block that exists only to explain it. | | `AGENTS.md` and any doc that names the gate as a standing check | **`AGENTS.md` names it zero times — re-measured here, see §1. This diff does not touch the governed surface.** | | `docs/audits/gate-census-2026-09.md:215` verdict | rewritten to the ruling's exact text, see §2. | | PR objectstack-ai#18708 | not touched. | | `check:test-source-alias` (the vitest-axis sibling) | not touched, and its ledger is not touched. | ## 1. `AGENTS.md`: the ruling's sentence describes a sentence that does not exist Re-measured independently of the dispatch, whitespace-**flattened** first so wrapped prose cannot give a false zero, every zero paired with a control drawn from the same flattened population: ``` type-source-resolution 0 check:test-source-alias 1 (control, hits) Type-source resolution 0 check: 47 (control, hits) type source resolution 0 test-source-alias 1 (control, hits) TYPE SOURCE RESOLUTION 0 KNOWN_DIST_RESOLVED 0 check-type-source-resolution 0 file 78,857 bytes / flattened 76,881 bytes ``` My reading **agrees with the dispatch's**. So the ruling's `AGENTS.md` clause has no referent, no line was hunted for there, and `AGENTS.md` / `CLAUDE.md` / `.claude/**` / `skills/**` / `docs/adr/**` are all absent from this diff. ## 2. The two censuses — deliberately different acts **`docs/audits/gate-census-2026-09.md` — verdict REWRITTEN.** Its verdict column is a forward-looking *disposition* (what should happen to the gate), which is exactly what a later ruling can override. The row's verdict now reads `retire · maintainer ruling 2026-09-18 on objectstack-ai#18373`, the ruling's own spelling. Because that is a NEW verdict spelling, the document's own verdict-count table was kept arithmetically true in the same edit: `keep` 133 to 132, a new row for the new spelling at 1, `retire (all spellings)` 59 to 60, `keep (all spellings)` 150 to 149. The union still sums to 225 rows. Nothing else on the row moved — class, contract, blast radius and the measured catch window are what the census measured, and this PR did not re-measure them. **`docs/audits/2026-09-self-test-shape-census.md:341` — deliberately LEFT ALONE.** The dispatch flagged it as a second carrier the ruling did not name; it holds a `ROSTER | HELD` row for this gate. It gets nothing, for a reason, not by omission: - That document's header pins it to a tree — `origin/main` at `d30ccb9bd`, re-verified at `1be26b0de`. Its rows are not dispositions; each one is **the result of a behavioural probe run against that sha**. Retiring the gate today does not make "at `d30ccb9bd` this script's self-test exited non-zero when it ran zero cases" untrue. - **Deleting** the row would break the document's own arithmetic — 179 total, 165 HELD, 4 DEFEATED, 1 ACCIDENT, 9 NOT MEASURED — and with it the whole reconciliation the document exists to perform against objectstack-ai#15410's competing count of 170. A record you can subtract rows from is not a record. - **Rewriting** the verdict would assert a measurement nobody took. Rule applied, and the same rule decides every prose carrier below: **a sentence that makes a present-tense claim about the gate acting is now false and is repaired; a sentence recording a past measurement or why a past change happened is not.** ## 3. The hard coupling: `check:ratchet-remedy-authority`, measured before and after That gate keeps a hand-classified control corpus keyed on gate FILENAME, and its self-test asserts the sweep reaches every entry. Both legs, run from this worktree: | leg | `--self-test` | main run | |:--|:--|:--| | **before** any change (at `02bdeaaf2`) | exit **0** | exit **0** — 257 scripts swept, 15 marked, 6 refused, control corpus 31 | | **after deleting the file only** | exit **1** — `the sweep still REACHES every known instance; it no longer reaches: check-type-source-resolution.mjs` | exit **1** — `STALE: the control corpus ... covers scripts/check-type-source-resolution.mjs, which is no longer in the corpus. Drop the entry, or restore the file.` | | **after the repair in this PR** | exit **0** | exit **0** — 256 scripts swept, 15 marked, 5 refused, control corpus 30 | **The repair is the gate's own prescribed remedy, and no floor moved.** What that gate pins is `SELF_TEST_BATTERIES` — a roster of battery NAMES with a per-battery count floor and a pinned roster SIZE, and its own comment at the roster says deleting an entry silences a floor as effectively as zeroing it. That roster is a **different registry** from the control corpus, and it is untouched in substance: ``` declared batteries: 21 SELF_TEST_BATTERY_FLOOR: 21 sum of counts: 30 battery (12) count: 1 (unchanged) ``` The control corpus (`CONTROL`) has no pinned size — the gate prints `Object.keys(CONTROL).length` — and its STALE branch names dropping the entry as the fix. 257 to 256 swept, 6 to 5 refused and 31 to 30 classified are the mechanical consequence of one file leaving the corpus, not a weakened floor. Three further carriers in that same file, each judged by the rule in §2: - `:16` "The precedents are ..." — present tense, names four files a reader is told to open. The dead name is dropped; the other three stay. - `:1221` the author-facing remedy "turn it down outright the way `check-type-source-resolution.mjs` does" — present tense, and after this PR it points an author at a file that does not exist. The exemplar is swapped to `check-test-source-alias.mjs`, the co-precedent of the identical PREDICATION shape that this same file already names at `:16` and in battery (12). ⛔ This names that gate; it does not touch it or its ledger. - battery (12)'s label and its assertion text ("the shape the two registry gates use") — present tense, now one gate. Label renamed, assertion reworded. Roster size and the battery's own count are unchanged, so nothing is unpinned. - `:662` "…which turned check-type-source-resolution's CORRECT remedy into a reported violation" — a record of a measurement that was taken and rejected. **Historical: kept.** ## 4. The coupling the dispatch did not name: `check-type-check-coverage.mjs` Found by re-measuring rather than by the brief. That gate's **live, author-facing** TEST_DEBT graduation remedy told an author route (b) was "Available ONLY while `pnpm check:type-source-resolution` still passes with the tests re-admitted ... Run it before you commit". After this PR that is a command that does not exist, in a message whose whole job is to tell an author which of two routes is open. Repaired so it keeps the WARNING and loses the dead instruction: it now records that the gate that decided the route was retired under this ruling, that its silence is ⛔ not a clearance, that what it measured has not changed (the re-admitted tests import workspace packages the src program never held; it read red on 14 of the 18 entries with an exclusion to drop), and that (a) is the route to prefer. **⛔ The self-test that pins that message is NOT weakened.** Its `present` needles (`check:type-source-resolution`, `SHRINK-ONLY`, `tsconfig.test.json`) and the sibling FUTURE_DEBT case's `absent` needles are left **byte-identical** — the rewritten message still carries all three, because it names the retired gate and its former registry explicitly. Only the case LABEL and its explanatory `why` changed. `pnpm check:type-check-coverage` exits **0** after the edit. The other five mentions in that file (`:934`, `:986`, `:1099`, `:4462`, and the `:537` / `:5629` provenance notes) are records of measurements — "MEASURED as a red `main`", "SINCE MEASURED ... by dropping each entry's exclusion and reading `check:type-source-resolution`", "measured by doing it". Under the §2 rule the measurements are kept; the two that also made a present-tense claim about a live consumer (`:537`, `:5629`) now say the gate was retired. ## 5. The other repo-root tooling carriers - `scripts/typecheck-configs.mjs` — this library existed *because* two gates needed the same predicate. One is gone. Its self-test does **not** assert a consumer set (checked: no consumer array, only prose), so nothing reds; but "Two gates need this predicate", "both consumers resolve", "the two callers" and ":201 `check-type-source-resolution.mjs` imports the predicates" were all present-tense and false. Repaired to name the one live consumer and record the retirement. ⛔ Folding the module back into its remaining caller is explicitly left as a separate decision — its cases are floored in its own dispatch (PR objectstack-ai#15327) and a fold-in would not inherit that floor. - `scripts/check-undeclared-dep-imports.mjs:42` — "the two gates that look adjacent" is now one. - `scripts/workspace-enumerator.mjs:66` — the `WORKSPACE_PARENT_GLOBS` declaration list named the deleted file; it now names the live one and records where the other went. - `scripts/pm/dispatch-gates.mjs` — five mentions, **all left alone**: every one is a recorded measurement in a docblock (pair counts of a matcher variant that was measured and refused). Historical under the §2 rule. The tool derives its families from `package.json` and the workflows at run time, so the retired gate simply leaves its output; it needs no edit and reds nothing. - `scripts/pm/check-clause2-carriers.mjs:8209` / `:8250` — **verified offline before deciding, and left alone.** The record is a frozen inline array of comment bodies passed `headSha: 'offline'`; nothing in it resolves a remote ref, and the two mentions are branch names inside a historical claim-contest fixture about this card, unrelated to the gate. - `scripts/typecheck-configs.mjs:202`'s stale comment about its importer — see above; that is the comment the dispatch flagged as pointing the other way. ## 6. Out of scope, on purpose - ⛔ **`packages/*/CHANGELOG.md` (five files) — untouched.** `AGENTS.md:686` is unconditional: a factual error in a released entry is repaired in a dedicated docs-only PR, ⛔ never as a rider on code changes. They are also *correct* as historical records of what those releases did. Same for `content/docs/releases/**` (which names it zero times anyway). - **About 30 prose carriers in `packages/**/tsconfig*.json` comments, test docblocks, `packages/cli/bin/run-dev.js` and `examples/*/tsconfig.json` — untouched, and reported to the PM as a residual.** Boundary applied: the ruling scoped this diff itself when it moved the lane to `domain:devx` 「the diff is repo-root tooling, `package.json` and the lint workflow」. Editing those comments would pull roughly 18 packages and 3 examples into the changeset, change the diff's lane, and multiply the derived gate set — for comments that mostly explain *why a `paths` rule exists*, a reason that outlives the gate. ## 7. The workflow step removal leaves the required context intact Measured, not asserted. The required context is the JOB's `name:`, and no context name is derived from a step: ``` BASE 02bdeaa : lint job `name:` = Lint & Repo Gates steps = 179 (step present) HEAD : lint job `name:` = Lint & Repo Gates steps = 178 (step absent) ``` The job keeps 178 other steps and its name is byte-identical, so the six required contexts are unchanged. `pnpm check:required-contexts` exits 0.⚠️ One wording note for the record: the ruling writes the job as 「Lint and Repo Gates」; the job's actual `name:` is `Lint & Repo Gates` (ampersand). Same job, and the ruling's conclusion holds. ## 8. `skip-changeset`, measured Criterion: nothing already published moves. Measured against every workspace manifest's `files[]`, with a positive control proving the reader works rather than merely reporting zeros. ``` changed paths (9) files[] reaches .github/workflows/lint.yml NONE docs/audits/gate-census-2026-09.md NONE package.json (private:true) NONE scripts/check-ratchet-remedy-authority.mjs NONE scripts/check-type-check-coverage.mjs NONE scripts/check-type-source-resolution.mjs NONE (deleted) scripts/check-undeclared-dep-imports.mjs NONE scripts/typecheck-configs.mjs NONE scripts/workspace-enumerator.mjs NONE POSITIVE CONTROL — must be reported as reached packages/spec/src/data/query.zod.ts @objectstack/spec (glob entry) packages/cli/dist/index.js @objectstack/cli (directory entry) packages/spec/CHANGELOG.md @objectstack/spec (literal entry) ``` 3 of 3 controls hit, across two packages and all three `files[]` entry kinds, so the zeros above are readings and not an empty read. The root `package.json` is `private: true` and is never published at all. ## 9. Verification Gate set derived in-worktree with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths passed — the tool takes its own change set from the merge base), then run. Union taken at `13b2b68ef`, the final commit. - **69 of 69 derived commands run. 63 exit 0.** - **6 exit 3 = `PREREQUISITE NOT MET`, NOT MEASURED, declared to CI**: `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, `check:type-check-debt` and `@objectstack/lint check:doc-formula-expressions`. Every one of them refuses because this worktree has no build; each prints its own "this is NOT a pass" line and exits 3 rather than 1. They are derived from the root `package.json` edit, and they read the `dist/` of packages this diff does not touch — this diff changes no package source, so their verdict cannot depend on it. CI's build lanes measure them. - `pnpm lint` (`eslint . --no-inline-config`, the whole repo, no narrowing) — exit **0**. - `pnpm check:ratchet-remedy-authority` — exit 0, before/after table in §3. - `pnpm check:type-check-coverage` — exit 0. - `pnpm check:pm-dispatch-gates` — exit **0**, `dispatch-gates self-test: 1848 cases pass` (976.3s on this box; run on its own because it does not fit a ten-minute foreground window). - `pnpm check:required-contexts`, `check:step-collectors`, `check:self-test-wired`, `check:self-test-workflow-commands`, `check:aggregator-roster`, `check:scripts-symbol-anchors`, `check:declaration-mirrors`, `check:ci-filter-parity`, `check:nul-bytes`, `check:workflow-step-name-quoting` — all exit 0. - Control-byte self-scan over every changed file (`grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'`) — clean. -⚠️ `dispatch-gates` prints its own warning that `--commands` is **not** a complete account of CI; the artifact-roster, wide-population, workflow-valued and path-scheduled CI families are outside that list by construction. -⚠️ `dispatch-gates` also reported a STALE TREE note: `origin/main` moved 3 commits after this branch point and `scripts/pm/check-widening-tells.mjs` changed there. The derivation itself uses three-dot merge-base semantics, so the change set above is correct; the note affects only that one family's shape. No path of this diff overlaps it. ## 10. Serial constraint with two in-flight PRs Both **objectstack-ai#18889** (draft) and **objectstack-ai#18414** (open, non-draft) also edit `.github/workflows/lint.yml` and the root `package.json`. Re-checked immediately before pushing: **both are still open and unmerged**, so neither had landed under this branch. This diff is written to survive either landing first — ⛔ no line number was used as a reading: - the workflow step is located by **its own name**, and the edit asserted the literal text of `- name: Type-source resolution gate`, its `run:` line and the first line of its comment block before removing anything; a shifted file fails the assertion instead of deleting the wrong step. - the `package.json` entry is matched as a **unique exact string**, never by offset. The ruling's `.github/workflows/lint.yml:4187` and the dispatch's `package.json:172` were both treated as clues; both happened to still be correct at `02bdeaaf2`, but nothing here depends on that. Also re-measured against a freshly fetched `origin/main` (`46559f61c`, five commits past this branch point): **none of those five commits touches any of this diff's nine paths**, so no merge was needed and no line re-derivation was owed. **objectstack-ai#18708 is closed, unmerged** (2026-09-18T06:37Z) — confirmed here, not assumed. This PR does not touch it. **objectstack-ai#18903** is editing `scripts/pm/check-clause2-carriers.mjs`, the file holding the frozen objectstack-ai#18708 fixture this PR deliberately leaves alone — adjacent, not overlapping. ## Acceptance notes - Noted, not filed: the gate census's inventory counts (182 check files, 225 rows) are pinned to the census's own tree and were deliberately not re-derived — only the verdict column and its roll-up were touched. Carrier for a future re-derivation: whoever executes the next batch of the 58 remaining `retire` rows. - Noted, not filed: `docs/audits/gate-census-2026-09.md` now carries a verdict spelling (`retire · maintainer ruling ...`) that no other row uses, where the existing convention for a ruling-driven retirement is the verdict `retire (ruled)` plus `ruled retire: #NNNNN X` in column 3. The ruling's literal text was followed rather than the convention. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…e the 27 signature hashes (objectstack-ai#18971) Fixes objectstack-ai#16045 Clause-②: yes (widening) Ruled at `5560224701` (director batch objectstack-ai#60, 2026-09-06, maintainer verbatim 「同意」), re-affirmed by triage at `5724532096`: option A, a readable declaration-text snapshot, ⛔ not a hash. The card body's three mutually exclusive routes predate that ruling and were not re-litigated here. `@objectstack/spec` pinned its public surface on one axis. `api-surface/` records each export as `name (kind)`, and a signature change, a renamed interface field and a dropped union member move **none** of those rows. The only shape pin was `api-surface-signatures.json`: 27 rows, and reference-level even there. This adds `api-surface-declarations/`, the declaration text the packed build actually emits for every export of every published entry point, and retires the 27 hashes it subsumes. ## The counts, re-derived on this head before the first generation The ruling asks for this by name; the card's own numbers were self-declared unverified and 12 days old. | Number | Card | This head (`b33898f5d`) | Unit, and what would make it something else | |---|---|---|---| | entry points | 17 | **17** | type entry points in the `exports` map — those whose `require.types` ends in `.d.ts`. Adding or removing one such subpath. | | `exports` map entries | (not stated) | **19** | every key in the map. The extra two are `./openapi.json` and `./package.json` — asset subpaths with no declaration at all, filtered out by the same `.d.ts` test `build-api-surface.ts` has always applied. ⇒ premise 1 resolved: **17 is right and the map did not grow**; 19 counts two things that were never entry points. | | pinned rows | 5309 | **5336** | `name (kind)` rows summed over the 17 `api-surface/` shards. +27 since the card. Ratio unmoved: 27/5336 = 0.51%, so the headline 99.5% stands. | | distinct exported names | (not stated) | **5200** | (entry, name) pairs. The gap to 5336 is dual-declared names, which are two rows by design. | | signature hashes | 27 | **27** | top-level keys of `api-surface-signatures.json`. Bright control: the first value really is a `sha256:` string, so this counts signature entries and not empty objects. | Premise 3 also holds: all 17 packed `.d.ts` files exist and resolve through the map (3,215,437 bytes for the root entry down to 13,081 for `./integration`). No entry point lacks a packed declaration, so the gap the dispatch reserved for itself did not open. ## What the artefact costs — premise 4, which nobody had costed | | | |---|---| | shards | 17, one per entry point | | declaration blocks | 5336 | | bytes | **12,661,943 (12.08 MiB)** | | lines | **237,706** | | gzipped | **1,071,825 (1.02 MiB)** — against this package's ~17.57 MiB compressed `dist`, so about **+5.8%** of tarball | | largest shard | `system.txt`, 3,592,701 bytes / 73,283 lines | | median declaration | **81 bytes** | | skew | the 20 largest declarations hold **~65%** of all bytes; four exceed 20,000 lines each (`EnvironmentArtifactSchema` 21,868, `ObjectStackDefinitionSchema` and `ObjectStackSchema` 21,851, `ChangeSetSchema` 20,395) | Stated plainly, as the dispatch asks, and ⛔ not as a veto: the packed `.d.ts` is a tsup dts rollup, so a Zod schema's declaration is its **fully expanded** structural type. That expansion is exactly what makes an inner field rename visible — and it is also why a single schema can produce a 21,000-line diff. The ruling's stated reason for choosing text over a hash is that the contract-review seat reads the diff; that reasoning holds per declaration and is worth a second look at the top twenty. One reading, for whoever wants it: 31% of declarations hold 97.7% of the bytes, so nothing cheap is available by trimming the tail. ## Both instruments, measured on one tree at one commit The card's thesis is that the old pin cannot fail on a shape change. Not argued — ablated, with the mutation proven on disk by blob hash and the mutation proven to have reached `dist/` before any verdict was read. **A. the source-level control — a renamed interface field, the card's own class.** `JobRunOutcome.reason?` renamed to `degradationReason?` in `packages/spec/src/contracts/job-service.ts` (blob `363443e2` to `d4b1520c`), spec rebuilt, `ablation-dist-preflight` exit 0 confirming the marker reached the built artefact: ``` check:api-surface exit=0 "public API surface unchanged" [BLIND] check:api-surface-declarations exit=1 "~ JobRunOutcome (interface)" [SEES IT] ``` Restore leg: blob back to `363443e2`, rebuilt, `ablation-dist-preflight --absent` exit 0 (marker gone from all 214 built files), `git diff HEAD` clean, gate back to exit 0. **B. the gate can fail on its own artefact.** One field renamed inside `qa.txt` by hand (blob `3f5efb04` to `5b86fec2`, injected occurrences 1, deleted text 0): exit **1**, attributed to `TestSuiteSchema (const)`, failure text naming the regenerate command. Restored to the HEAD blob, `git diff HEAD` empty: exit **0**. ## The retirement, and the coverage proof the ruling demands All **27** signature names resolve to a declaration block in `api-surface-declarations/root.txt`, **0 missing** — enumerated from `defineAction` through `defineWebhook`, each as `(function)`. One honest qualification, because the subsumption is not uniform. For those 27 factory declarations the text is `declare function defineAction(config: z.input of ActionSchema): ActionParsed;` — a type **reference**, exactly as blind to an inner-key narrowing as `typeToString` was. What is gained is not sharper text on the 27; it is the **5309 other declarations**, including `ActionSchema` itself, whose own expanded block is where such a narrowing shows up. So the retirement is a strict superset of pinned declarations, not an equal trade. Nothing published read the retired file — it was never in this package's `files[]`. ## Where it lands, and why there - Generator: `packages/spec/scripts/build-api-surface-declarations.ts`, beside the eight sibling artefact generators, reading the same input through the same `collectEntries` logic. The ruling says "one generator script under `scripts/`"; this reads that as the directory the whole family lives in, because the artefact reads the **built dist** and only the lane that builds spec can run its gate. - Artefact: `packages/spec/api-surface-declarations/ENTRY.txt`, a sibling **directory** of `api-surface/`. Not inside it: `listShardNames` throws on any file in that directory that is not a `NAME.json` shard, so `api-surface/` is closed by construction. No existing `api-surface/*.json` is regenerated by this PR (`check:api-surface` green throughout), which keeps it clear of PR objectstack-ai#18688 and PR objectstack-ai#18319. - Gate: `check:api-surface-declarations`, a step in lint.yml's `Type Check · consumer gates` lane after the two build steps, with `check:api-surface` and the other dist-reading gates. **No new required context** — a step in an existing lane. Registered in the `check:generated` ledger, in `REGEN_ARTIFACTS`, and in `.gitattributes` as `merge=os-regen`. - Sharded per entry point from day one, for the reason its neighbour is: the merge queue rebuilds server-side where no custom driver runs, so two PRs sharing one generated file evict the second. Pit 1 from `5715457322` is answered by the layout rather than by an assumption — and `check:merge-driver`, which reconciles `.gitattributes` against `REGEN_ARTIFACTS` in both directions, is green over the swap. - Published, with the reason the gate demands. `check:published-files` refuses a `files[]` entry that carries none; the registered line says what a consumer does with it — read two published tarballs and see *which declared shape* moved between releases, the question `api-surface` cannot answer. If 1.02 MiB of tarball is judged too much, one line of `files[]` removes it without touching anything else. Three registries had to learn about the new gate, each because it discovered the gate on its own rather than because a list named it: - `check:published-files` — demanded the reason above. - `scripts/pm/dispatch-gates.mjs` — its live manifest edge gave the new gate a population before anything listed it, which is the eighth member of a class whose seventh was recorded the same way. Declared as `CLASS_EIGHTH`, with a case asserting the edge really reaches it. - `scripts/pm/check-widening-tells.mjs` — `PUBLISHED_SURFACES` is derived from `REGEN_ARTIFACTS`, so retiring the signatures row dropped it off that surface and reddened two self-test cases. Both are retargeted to state the retirement as a counterfactual (the surface follows the table, not a literal); ⛔ the new artefact is **not** added to that surface, because the ruling assigns "is a snapshot diff a Clause-② signal" to the skills seat by name and out of this card's scope. Both directions are now pinned, so the boundary is declared rather than forgotten. 483 cases pass, up from 481. ## Verification - **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from the merge base, 120 commands, every exit code redirected to a file and read back. **All 120 green.** Four returned exit **3** PREREQUISITE NOT MET on first pass (`check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`); each names a build, each was built and re-run green, and none is recorded as a finding. Reconciled with `--ran`. - **Tests**: `@objectstack/spec` local project **488 files / 14,182 tests passed**; the tooling suites that name the edited scripts, both projects, **10 files / 220 tests passed** (`sharded-artifacts`, `check-generated-ledger`, `dist-freshness`, `dist-freshness-adoption`, `api-surface-dual-kind-rows.pin`, `build-schemas-check-mode`, `def-key-collisions`, `root-index`, `export-list`, `docs-import-surface`). `pnpm --filter @objectstack/spec typecheck` green. - **eslint, the union rather than a narrowing**: `eslint . --no-inline-config --format json` at `b33898f5d` examined **6856 files**, **0 errors, 0 warnings**, exit 0. The population is eslint's own config resolution and the count is read from its JSON output; type-aware linting is not enabled in `eslint.config.mjs` (no `parserOptions.project`, no typed rules), so this diff cannot move an untouched file's verdict either way. - **Control bytes**: `check:nul-bytes` green over 8906 files, plus a direct scan of all 31 changed paths for the wider control-byte class — no matches. - `scripts/check-single-claim-paths.mjs` in the diffstat is **not mine**: it arrived with the one-commit `origin/main` merge (`16cb493d5`) this PR carries. ## Acceptance notes - `.claude/skills/spec-property-retirement/SKILL.md` line 124 lists `api-surface-signatures` as an instance of a retirement shape, and that row goes stale with this landing. ⛔ Left untouched on purpose: `.claude/**` is a governed surface, so editing it would make this whole PR maintainer-landed for a one-word prose nit. Noted, not filed. - `packages/spec/scripts/build-schemas.ts` line 830 carries the same stale mention. Left untouched because PR objectstack-ai#18952 holds that file; noted, not filed, with the later lander as the natural carrier. - Three files in this diff are held by open PRs and were edited anyway because the retirement forces it, not by choice: `scripts/pm/check-widening-tells.mjs` (PR objectstack-ai#18948), `scripts/pm/dispatch-gates.mjs` (PR objectstack-ai#18903) and `.github/workflows/lint.yml` (PRs objectstack-ai#18946, objectstack-ai#18889, objectstack-ai#18414). All are hand-written files where a text conflict is visible rather than silent, and all three of my hunks are small and far from theirs. Whoever lands second resolves. - The top-20 skew above is a reading, not a finding: no gate is wrong and nothing is unenforced. It is recorded here because the ruling's own justification for text over hash is per-declaration readability, and at 21,000 lines a declaration that argument thins out. ## 维护者速读(草稿) **改了什么。** `@objectstack/spec` 从今天起为它的**每一个**公开导出留一份"形状快照" —— 不是哈希,而是打包后 `.d.ts` 里那段声明原文,按入口点分成 17 个文件签入仓库,并配一道 CI 闸门:重新生成后对不上就红,失败信息里直接给出重新生成的命令。同时退休了旧的 27 条签名哈希文件。 **为什么改。** 原来的 pin 只记"某个名字还在不在",5336 行里只有 27 行能看出"形状变没变"。也就是说:把一个接口字段改名、砍掉一个联合成员、改一个函数签名 —— 这些都是会让客户升级后编译失败的破坏性改动 —— 全部一路绿灯。本次 PR 里有实测:改了 `JobRunOutcome` 的一个字段名之后,旧闸门 `check:api-surface` 退出码 **0**(看不见),新闸门退出码 **1**(点名了那个 interface)。路线是 2026-09-06 决策批次 objectstack-ai#60 里您逐字「同意」的那一条。 **风险与代价(含回滚)。** 代价是体积:12.08 MiB 文本、23.7 万行,压缩后 1.02 MiB,相当于 npm 包增长约 5.8%。更值得注意的是分布极不均匀 —— 最大的 4 个 schema 各自超过 2 万行声明文本,一旦它们变动,复核席位面对的是一份 2 万行的 diff;而裁决选"文本不选哈希"的理由恰恰是"diff 可读"。这一点我按实测如实报告,未自行改动路线。回滚成本很低:从 `files[]` 去掉一行即可停止随包发布;整道闸门回滚就是撤销本 PR,不留任何数据迁移。 **席位意见。** **你要做的。** 只有一件事需要您判断:12 MiB / 23.7 万行这个量级,以及最大 4 个 schema 的 diff 可读性,是否仍符合当初选 A 方案时的预期。若认为需要收窄,那是裁决层面的一次增补,不是本 PR 的返工。其余部分已按裁决落地并自证。 --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ease pair it really spans (objectstack-ai#19115) Fixes objectstack-ai#18978 Clause-②: yes (widening) — one new OPTIONAL key on a published artifact (`aggregate.surfaceScope`) and one new optional field on `SpecChangesSchema`. Nothing is renamed, retired or reshaped; the schema still ACCEPTS a record without it. Contract-review tier. `spec-changes.json`'s `aggregate.added` / `aggregate.removed` are filled by a release-time api-surface diff of the artifact being published against the previously **published** one, so they span **one release** — under a record keyed by protocol major (`from: 10, to: 17`), with every entry carrying only `since: 17` / `removedIn: 17` and `perMajor[16 → 17].added` sitting at `0` beside it. Nothing in the file distinguished one minor's slice from the whole major-boundary delta. --- ## 1 · The defect, re-measured on a real published artifact Instrument: `curl` the Release asset through the REST API, then recompute the delta with a **hand-written flattener in Python** (not this repo's code) over the two published tarballs' own `api-surface/` shard directories. | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **Release asset** `aggregate.added` | **225**, `since` counter `{17: 225}` | | same asset, `aggregate.removed` | **51**, `removedIn` counter `{17: 51}` | | same asset, `aggregate.from` / `aggregate.to` | `10` / `17` | | same asset, `perMajor[16 → 17]` | `added: 0, removed: 0` (converted 57, migrated 77) | | same asset, `release` section | **absent** (generated 2026-09-09, before objectstack-ai#18889) | | independent recompute, `npm pack` 17.3.0 vs 17.4.0 `api-surface/` | **added 225, removed 51** | | set equality, asset arrays vs recompute | `added` **True**, `removed` **True**; 0 only-in-asset, 0 only-in-recompute, both directions, both arrays | So the published arrays are, byte for byte, the **17.3.0 → 17.4.0** one-minor delta, wearing a `10 → 17` label. Cross-check: PR objectstack-ai#17080's own changeset states the same pair as "gained 225 exports and lost 51". ### One refinement to the card's premise, stated because it moves a date, not a verdict | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | `@objectstack/spec@17.3.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | npm publish time of 17.4.0 | `2026-09-09T03:57:51.929Z` | | merge time of objectstack-ai#18889 (`8b4890343`) | `2026-09-18T11:16:13+00:00` | ⇒ **no published tarball carries the mislabelled arrays yet.** The lane that will is on `origin/main` today: `release.yml` runs `release-spec-changes.sh --prepare` (line 1251) and `--verify` (1260) **before** the publish, then `--attach` (1355), and `--prepare` invokes the generator with `--previous-package`. The card's "reach is new" premise therefore holds as a property of the lane, and the first tarball to carry it is the next publish. Today's carrier is the Release-page asset, measured above. This is a sharpening, not a disproof — nothing in the card's argument depends on a tarball already existing. --- ## 2 · The A/B legs, re-taken Base: `origin/main` at `07c6f822e`. Previous artifact: `npm pack @objectstack/spec@17.3.0`, unpacked. Both legs write the real snapshot path, so each was copied out and the tree restored by `git checkout HEAD -- packages/spec/spec-changes.json` with the blob hash re-read each time (`9dbc98682…` in, `9dbc98682…` out, `git diff HEAD` empty, `git status --porcelain` empty). | leg | what ran | result | |:--|:--|:--| | **A** | HEAD generator, `--previous-package PKG_DIR` | `aggregate.added` **399** (`since` counter `{17: 399}`), `aggregate.removed` **302** (`removedIn` counter `{17: 302}`), `perMajor[16 → 17]` **0 / 0**, `release` 17.3.0 → 17.4.0 with 399 / 302 | | **B** | generator at `43f4766889e` — objectstack-ai#18889's parent, verified **0** occurrences of the string `--previous-package` on disk and 3 of `--previous-surface` — invoked with `--previous-surface` | `aggregate`, `perMajor`, `protocolVersion`, `supportFloor`, `migrateCommand` all **canonical-hash identical to leg A** (`aggregate` = `d8c3e5c4303c2ecc` on both) | Whole-document diff between the two legs: the `release` key (leg A only) and `$comment` (which objectstack-ai#18889 extended). Nothing else. ⇒ **the computation is pre-existing**, exactly as the card claimed. One reading the card did not state, and it is the sharpest one: in leg A, `aggregate.added` / `aggregate.removed` are **set-identical to `release.added` / `release.removed`**. The aggregate record does not merely resemble a one-release slice — it *is* the release slice, under a major-resolution header. --- ## 3 · ⭐ The consumer survey the card named as unmeasured **Question:** who reads `aggregate.added` / `aggregate.removed` today? ### Radius, declared | # | in radius | how read | |:--|:--|:--| | R1 | `objectstack-ai/objectstack` @ `origin/main` `07c6f822e` | `git grep -I` over tracked **and** untracked files, whole tree, no `head` anywhere | | R2 | `objectstack-ai/objectui` @ `origin/main` `05a49f2ee` (fetched for this survey) | `git grep -lI PATTERN origin/main` | | R3 | the published tarball's own contents | `npm pack` 17.3.0 and 17.4.0, plus `packages/spec/package.json` `files[]` | | R4 | the documented / prescribed consumers | `content/docs/upgrading.mdx`, `skills/objectstack-upgrade/SKILL.md` (the **published** skill catalog), `docs/adr/0087` | **Outside the radius, named as outside it:** the `objectstack-ai/cloud` repository (not checked out in this container); any third-party or private consumer of the npm artifact or of the Release-page asset; and the `spec_changes` MCP tool, which is **prose only** — `git grep spec_changes` over R1 returns docs, ADRs, changelogs and code comments and **zero implementation**, so there is nothing there to read anything. ### Instrument, in two stages - **Stage 1 — population.** Every site naming the literal `spec-changes.json`, plus every site naming a key that is distinctive to this manifest (`perMajor`, `supportFloor`). Enumerable and small; each hit was then read. - **Stage 2 — field classification.** For each member of that population, which top-level keys it actually reads. ### ⭐ Lit controls, so a zero is a reading | control | instrument | result | |:--|:--|:--| | L1 · a site that provably reads a field of `spec-changes.json`, found by stage 1 | `git grep -n "spec-changes\.json"` | **found** `packages/cli/src/utils/spec-release-changes.ts:80`, which reads `doc.release` at line 106 — a real, shipping reader | | L2 · the distinctive-key instrument is not dead | `git grep -n perMajor` / `supportFloor` | **found** the producer, two gate fixtures, and the published `skills/objectstack-upgrade/SKILL.md:219` + its `node -e` snippet at 229-236 | | L3 · the instrument reaches objectui at all | `git grep -lI PATTERN origin/main` in `../objectui` | `@objectstack/spec` → **1628** files; `api-surface` (another published spec artifact) → **3** files | ### Result | consumer | radius | reads | reads `aggregate.added` / `removed`? | |:--|:--|:--|:--| | `packages/cli/src/utils/spec-release-changes.ts:106` (ships in `@objectstack/cli`) | R1 / R3 | `doc.release` and the lengths of its four arrays | **no** | | `scripts/check-release-spec-changes.mjs` `aggregateIds()` | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId`, `release.*` | **no** | | `packages/spec/scripts/build-spec-changes.ts` `previousRelease()` (reads the PREVIOUS tarball) | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId` | **no** | | `scripts/check-adr-0087-registration.mjs` (parser-rot witness) | R1 | `migrationId` occurrences | **no** | | `skills/objectstack-upgrade/SKILL.md` — **published** to customer projects | R4 | `perMajor[].converted`, `perMajor[].migrated`, `protocolVersion`, `supportFloor` | **no** | | `content/docs/upgrading.mdx` | R4 | `.release.*`; and, for withdrawals only, `.aggregate.converted[].conversionId` / `.aggregate.migrated[].migrationId` | **no** | | whole `objectui` repository | R2 | nothing — `spec-changes` → **0** files, `perMajor` → 0, `supportFloor` → 0, `spec_changes` → 0 | **no** | | `spec_changes` MCP tool | R1 / R4 | does not exist as code | n/a | | `scripts/regen-artifacts.mjs`, `check-regen-pending.mjs`, `objectui-changeset-digest.mjs`, `check-published-files.mjs`, `docs-audit/affected-docs.mjs` | R1 | the **path**, as a ledger row — never a field | **no** | ⇒ **Zero readers of `aggregate.added` / `aggregate.removed` in the reachable radius.** Every field-level reader of `aggregate` reads `converted` / `migrated` only. Confirming probes: `git grep -nE "aggregate(\.|\[[\"'])(added|removed)"` over R1 returns **0 rows**; the loosened, case-insensitive variant returns 11 rows, all the English phrase "aggregate added to the spec" about SQL aggregate functions. **But there is a declared contract, and it is the one the defect breaks.** `content/docs/upgrading.mdx:338` says, of this very field: "The same file's `aggregate` and `perMajor` records are unchanged and still answer the **major-boundary question**." They do not. That sentence is the class-(b) contract text — a machine-readable surface that does not say what it means — and it is what makes this a defect rather than an unused field. ### Why the survey licenses the shape taken The dispatch allows two shapes: **gate** the aggregate arrays as `release.*` is gated, or **relabel** them at the resolution they actually carry. Gate-only cannot be the whole fix here, and that is a measurement, not a preference: there is no computable "correct" 10 → 17 export delta to gate against, because tarballs before protocol 15 ship no `api-surface` snapshot at all. A gate that merely refused today's shape would wedge every release until the producer changed — and the producer changing **is** the relabel. So the gate is not an alternative to the relabel; it is the **negative control for it**. And with **zero** readers, relabelling is free: nothing downstream can break, so the honest fix is available at no migration cost. That is what the survey buys. ⛔ **Not taken, and reported instead:** removing the fields, or ceasing to emit them. The survey lands exactly where the card guessed it might — nobody reads them — so the removal question is live, and it is the maintainer's. See `## Acceptance notes`. --- ## 4 · What changed A record whose export arrays are non-empty now carries the version pair they were diffed between: ```json "aggregate": { "from": 10, "to": 17, "surfaceScope": { "fromVersion": "17.3.0", "toVersion": "17.4.0" }, "added": [], "removed": [] } ``` - **`packages/spec/src/migrations/spec-changes.ts`** — `SpecSurfaceScopeSchema` + `SpecSurfaceScope`, an optional `surfaceScope` on `SpecChangesSchema`, `SurfaceDiff.scope`, and `surfaceScopeProblem(record)`, which is the refusal. The record spreads the key in rather than assigning `undefined`, so a record with no export diff serialises exactly as before. - **`packages/spec/scripts/build-spec-changes.ts`** — reads the previous version off the previous artifact's own `package.json` (`--previous-package PKG_DIR`, or the sibling of a `--previous-surface` snapshot), OMITS the arrays loudly when it cannot, and refuses outright to write a non-empty unlabelled array. - **`scripts/check-release-spec-changes.mjs`** — `verifyAggregateSurface()` recomputes the aggregate's claim from the same two tarballs the release section is checked against, and refuses an absent, mislabelled or untrue scope in both directions. Self-test roster **15 → 23** batteries. The failure headline now names which claim disagreed. - **`packages/spec/src/migrations/spec-changes-surface-scope.test.ts`** — new. - Regenerated: `packages/spec/spec-changes.json` (one line — its `$comment`) and `packages/spec/api-surface-declarations/root.txt` (+6 / -0). ⛔ **Not narrowed on purpose.** `SpecChangesSchema` still accepts an unscoped diff, because every manifest published so far carries one and a schema that refused them would narrow what an already-shipped artifact parses as. The refusal lives at the producer and at the publish gate. ⛔ **`packages/spec/src/migrations/registry.ts` was not touched** (held by objectstack-ai#19095, objectstack-ai#19090, objectstack-ai#19084, objectstack-ai#18319). The change is additive, so it declares no ADR-0087 disposition and needs no migration entry: `node scripts/check-adr-0087-registration.mjs --base origin/main` → "this PR adds no declared-breaking changeset". `scripts/regen-artifacts.mjs` (held by objectstack-ai#19024) and `content/docs/releases/**` were not touched either. The public entry barrel `packages/spec/src/migrations/index.ts` was deliberately left alone, which is why `check:api-surface` is green with no export-name churn. --- ## 5 · ⭐ Acceptance controls ### Control 1 — a test that fails on today's composition (acceptance 1) Ablation via `node scripts/ablation-replace.mjs`, which proves the mutation reached disk before running anything: ```text ablation-replace: anchor "...(surfaceDiff.scope ? { surfaceScope: surfaceDiff.scope } : {})," x1 (before) ablation-replace: anchor x1 -> x0 ablation-replace: replace "// ABLATION: the composer drops the scope..." x0 -> x1 ablation-replace: blob 2e046d0 -> d0c1189d0f233a8d46b2641812713acf33a50181 ablation-replace: ok mutation landed: anchor 1 -> 0, blob 2e046d0 -> d0c1189d0f23 VITEST_EXIT=1 Test Files 1 failed (1) Tests 2 failed | 5 passed (7) ablation-replace: blob after restore 2e046d0 ablation-replace: blob at HEAD 2e046d0 ablation-replace: ok restored: blob == HEAD (2e046d0) and `git diff HEAD` is empty ``` The reported failure is the real one: `expected 'the 10 → 17 record carries 2 added and 1 removed export(s) with no surfaceScope…' to be null`. Unablated: **7 / 7 pass**. No ablation artefact remains — restore proved by blob equality with `HEAD` and an empty `git diff HEAD`, not by an exit code.⚠️ Reported honestly: the first run of this ablation piped vitest into `tail`, so the wrapper printed `command exited 0` while the suite had failed. The run above redirects first and captures `$?` before any pipe. Only the second reading is cited. ### Control 2 — ⭐ preserved truth (acceptance 2), shown rather than asserted Same generator invocation, same real 17.3.0 tarball, before the fix and after; every record compared by canonical JSON: ```text perMajor identical=True protocolVersion identical=True supportFloor identical=True migrateCommand identical=True release identical=True aggregate MINUS surfaceScope identical=True (the only added key: {'fromVersion': '17.3.0', 'toVersion': '17.4.0'}) $comment identical=False (documents the new key) ``` And on the **committed** artifact, per-key against `HEAD`: `aggregate` unchanged, `perMajor` unchanged, `protocolVersion` unchanged, `supportFloor` unchanged, `migrateCommand` unchanged, `$comment` changed — a one-line diff (`1 insertion, 1 deletion`). The per-release section objectstack-ai#18889 added is untouched in both readings, and `composeReleaseChanges` still returns exactly its six keys (pinned in the new test). Two further preserved-truth readings: all **15** pre-existing gate self-test batteries still pass unchanged, and `pnpm --filter @objectstack/spec check:generated` reports "All 16 generated artifacts are up to date". ### Control 3 — ⭐ a negative control that distinguishes fixed from switched off (acceptance 3) The gate run against four constructed publish trees, each carrying the real committed `api-surface/` and a real `package.json`, with the real unpacked 17.3.0 tarball as `--previous`: | input | what it is | gate | |:--|:--|:--| | `good` | the post-fix generator's own output | **EXIT=0** — "release 17.3.0 → 17.4.0 verified … 399 added, 302 removed … aggregate export diff 17.3.0 → 17.4.0 verified: 399 added, 302 removed." | | `bad-prefix` | the **genuine, unmodified pre-fix artifact** — what `main`'s generator produces today | **EXIT=1** — "aggregate.surfaceScope is absent while aggregate.added/removed carry 701 export(s). … Expected { fromVersion: "17.3.0", toVersion: "17.4.0" }." | | `bad-unscoped` | post-fix output with `surfaceScope` deleted | **EXIT=1**, same refusal | | `bad-wrongscope` | `surfaceScope.fromVersion` set to `17.2.0` | **EXIT=1** — "the export diff was taken against a different release." | The `bad-prefix` row is the load-bearing one: the new gate refuses the artifact today's code actually produces, so it is a check that can still fail rather than one that was switched off. Eight further refusals are pinned as self-test batteries (absent scope, wrong `fromVersion`, wrong `toVersion`, an invented export, an omitted real removal, a claim the previous tarball could not have produced), each alongside two GREEN batteries — a matching scoped claim, and the unscoped-empty registry-only projection that must stay accepted. The producer half, both directions: ```text $ tsx scripts/build-spec-changes.ts --previous-surface ORPHAN_DIR/api-surface No aggregate export diff: the previous artifact at ORPHAN_DIR/api-surface carries no readable package.json, so the version pair the diff spans cannot be read. Omitting `added`/`removed` — an unlabelled one-release slice under the major-keyed aggregate record reads as the whole from → to delta. -> aggregate added 0 removed 0 surfaceScope None $ tsx scripts/build-spec-changes.ts --previous-surface PREV_PKG/api-surface -> aggregate added 399 removed 302 surfaceScope {'fromVersion': '17.3.0', 'toVersion': '17.4.0'} ``` ### Control 4 — the card's own numbers, re-measured after the change (acceptance 4) Instrument: HEAD generator, `--previous-package` pointed at the unpacked published 17.3.0 tarball; counters computed by `collections.Counter` over the emitted JSON. | reading | post-fix value | |:--|:--| | `aggregate.from` / `to` | `10` / `17` (unchanged — it still answers the major question for `converted` / `migrated`) | | `aggregate.added` | 399, `since` counter `{17: 399}` | | `aggregate.removed` | 302, `removedIn` counter `{17: 302}` | | `aggregate.surfaceScope` | `{fromVersion: 17.3.0, toVersion: 17.4.0}` ← **new; this is the fix** | | `perMajor[16 → 17]` | `added: 0, removed: 0` (unchanged, and now honest by construction: the record says nothing about exports) | | `release` | 17.3.0 → 17.4.0, 399 added / 302 removed (unchanged) | --- ## 6 · Verification | what | result | |:--|:--| | `pnpm --filter @objectstack/spec build` (forced fresh, under the shared verify lock) | `VERDICT command-exit 0`; `check-dts-emitted: 34/34` | | `pnpm --filter @objectstack/spec typecheck && … test` (under the lock) | `VERDICT command-exit 0` — **495 test files, 14527 tests, all passing** | | `node scripts/check-release-spec-changes.mjs --self-test` | EXIT=0 — **23 batteries pass** (15 pre-existing + 8 new) | | `pnpm --filter @objectstack/spec check:generated` | EXIT=0 — all 16 artifacts up to date | | `pnpm lint` (full repo union, at final commit `0c548868c`) | EXIT=0 — **6878 files linted, 0 errors, 0 warnings** (`--format json` counts) | | `pnpm check:nul-bytes` | EXIT=0 — 8952 text files, no raw control bytes | | `@objectstack/cli` unit tier, `src/utils/spec-release-changes.test.ts` | 6/6 pass — the one downstream reader of this artifact | | gate families derived from the diff (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`) | 103 commands over 7 paths; **43 run green** locally, listed in the report | | `pnpm check:type-check-debt` | **EXIT=3 · PREREQUISITE NOT MET — NOT MEASURED**: `--re-measure` needs the whole workspace build closure on disk and only `packages/spec` was built. Its own words: "This is NOT a pass and NOT a finding". Its non-re-measure invariants reported 0 findings on all three layers. Left to CI, which builds the closure first. | Downstream reach, with a lit control: `git grep -nE "\b(SpecChangesSchema|SurfaceDiff|SpecSurfaceAdd|SpecSurfaceRemove)\b"` outside `packages/spec` returns **0 rows**; the same instrument finds `composeMigrationChain` (a sibling export of the same module directory) in `packages/cli/src/commands/migrate/meta.ts`. ⇒ no package outside `packages/spec` names any changed declaration, so no other package's tests are owed. Export **names** are unchanged (`check:api-surface` green); only declaration text moved (`api-surface-declarations`, +6 / -0). --- ## Acceptance notes 1. ⭐ **The removal question is live, and it is the maintainer's.** The survey found **zero** readers of `aggregate.added` / `aggregate.removed` in the whole reachable radius. The card itself floats "if the answer is nobody, the cheapest honest fix may be to stop emitting it". It was ⛔ **not** implemented here — removing a published machine-readable capability is a maintainer decision — and this PR makes the surface honest instead, which is strictly compatible with a later removal. Recorded as an open question. 2. **`content/docs/upgrading.mdx` is corrected here, not merely reported.** Line 338 said 'The same file's `aggregate` and `perMajor` records are unchanged and still answer the major-boundary question'. That is true of `perMajor`, and of `aggregate.converted` / `aggregate.migrated`, which are registry-derived across the whole range — and it was never true of `aggregate.added` / `aggregate.removed`. The page was the declared contract this artefact did not keep, so correcting it is the doc half of this defect rather than opportunistic cleanup. The path was measured FREE of open-PR holders first (32 open PRs, 364 file rows, instrument lit by all four holders of the migrations registry). 3. **A deliberate boundary in the new gate, so nobody reads it as an oversight.** It refuses a *wrong* aggregate claim; it does not *require* the published artifact to make one. An aggregate with empty arrays and no `surfaceScope` is accepted, because that is the honest registry-only projection. Turning "must not lie" into "must speak" would be a new publish requirement, and that call is not this gate's. The residual hole is narrow: a bug that silently emptied `aggregate.added` while `release.added` stayed correct would pass. Worth a card if the maintainer wants the stronger rule. 4. **`--previous-surface` has no caller left in the repository.** `git grep -- "--previous-surface"` finds only the generator's own argv parsing and its docblock; every lane uses `--previous-package` (`scripts/release-spec-changes.sh:87`). It was kept working — and taught to derive its scope from the snapshot's sibling `package.json` — rather than retired, because retiring a flag is not this card. 5. **`cut-rc.yml` attaches, and never prepares.** It calls `bash scripts/release-spec-changes.sh` with no mode, which defaults to `--attach`, so the RC lane uploads the committed registry-only manifest and never runs `--verify`. Not a defect (the committed copy claims nothing), and not this card — noted because it is the one lane the new gate never sees. 6. **No label was applied by this PR.** `Clause-②: yes` means it and objectstack-ai#18978 owe `needs:contract-review`; that label is the seat's to apply and ⛔ never this branch's to clear. 7. **The two regenerated artefacts were written by the repo's own generators, never by hand.** `packages/spec/spec-changes.json` by `pnpm --filter @objectstack/spec gen:spec-changes`; `packages/spec/api-surface-declarations/root.txt` by `pnpm --filter @objectstack/spec gen:api-surface-declarations`. Both were named stale by `pnpm --filter @objectstack/spec check:generated` first, and only those two were regenerated (`--fix` is deliberately narrow). No `origin/main` merge was performed on this branch, so the `merge=os-regen` silent-resolution hazard on that path was never entered. 8. **The docs-drift bot's three hand-written rows, answered.** `content/docs/api/client-sdk.mdx` and `content/docs/kernel/contracts/metadata-service.mdx` are **still accurate**: both were anchored by a NAME COLLISION on the generic identifiers `fromVersion` / `toVersion` between this PR's new published-version STRINGS and the REST metadata-history routes' INTEGER version parameters (`rest-server.ts:8209` reads `body.toVersion` for `POST /meta/:type/:name/rollback`; `client-sdk.mdx:229-230` spells the SDK keys `from` / `to`; `metadata-service.mdx:87` declares `version: number`). Neither page mentions `spec-changes` at all. `content/docs/upgrading.mdx` is the one genuinely-mine row and is corrected in this PR. ⛔ `content/docs/releases/v17/17-1.mdx` is release-owned and was not edited — it is also **not wrong**: the same collision put it there, its only mention of the route is line 294 in a security context, and it never names `spec-changes`. 9. **The bot's own blind spot, answered by reading rather than by trusting its run.** It declared that `api-surface-declarations/root.txt` and `spec-changes.json` yielded no anchor, so pages documenting those are outside its run — and `spec-changes.json` is this card's subject. A full read of `content/docs/**`, `docs/**` and `skills/**` finds **exactly one** page stating a claim about the aggregate export arrays' resolution: `upgrading.mdx:338`, corrected here. The published `skills/objectstack-upgrade/SKILL.md` points only at `perMajor[].converted` / `perMajor[].migrated` / `protocolVersion` / `supportFloor` — all unaffected and all still true. `content/docs/releases/v15.mdx:521-523` claims only that the file is generated, ships and attaches: still accurate. `docs/adr/0087:210-213` states no falsehood (its 'compose' claim is about the registry-derived arrays), though it is where the ambiguity originates — a governed-surface question, left to the maintainer. <sub>⚠️ Notes 2, 7, 8 and 9 were written into this body by the dispatching seat (`Seat: domain:spec#3`, `session_019srGWGCBBCBHqcDoRZpQRh`) at 2026-09-18T21:06Z, from the implementing dev's final report. The dev correctly refused to PATCH this body: `.claude/agents/os-dev.md:56` says the PR body is written once, on the call that opens the PR, and later corrections are named in the report for the seat to write — and `:184` makes that clause govern over any dispatch word. ⛔ Nothing else in this body was touched, and ⛔ no verdict about the diff is written here: the clause-② review is an isolated at-tier reviewer's, and `needs:contract-review` stays on both carriers until it lands.</sub> --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17080
Clause-②: yes (widening)
Ruled A (issue comment 5643392133, decision batch #119 item 2, maintainer 「同意」 2026-09-12):
minors ship real, machine-readable change data, with a correctness gate. All four execution
clauses land here.
The defect, re-measured from the published tarballs
npm pack @objectstack/spec@17.3.0 @objectstack/spec@17.4.0, then the repo's own export-surfacerow convention (
ENTRY: NAME (KIND)— entry point, export name, export kind — the spellingbuild-spec-changes.tsflattens to):spec-changes.json→aggregate.added/.removedprotocolVersionperMajor[16→17][REMOVED]prescriptions injson-schema/**Every figure the card reports reproduced. The shipped manifest answered
0 / 0over a releasethat moved 276 exports, and a consumer reading semver sees a minor.
What lands
1 · A per-release section, shipped in the tarball.
spec-changes.jsongainsrelease—fromVersion→toVersionat package-version resolution, withadded/removed(the exportsthat arrived and left, each named) and
converted/migrated(the ADR-0087 D2/D3 entries firstregistered in that release). Generated at publish time only, from the previously published
tarball: the committed copy stays the deterministic registry projection and
check:spec-changesis green against it, which is how the determinism concern is answered rather than traded away.
2 · The correctness gate, as acceptance. Before anything reaches npm, the release lane packs
the artifact it is about to publish and recomputes the delta from the two tarballs, with its
own reader —
scripts/check-release-spec-changes.mjsdeliberately does not import the generator'sflattening, because an instrument that shares the code it audits reports agreement with itself.
A mismatch fails the release, naming the disagreeing exports and the direction of each
disagreement (claimed-but-not-real, real-but-unclaimed, per array) plus the command that
regenerates the section. A held release arrives with its own diagnosis.
3 ·
os validate --jsonreads the same data. New keyspecReleaseChanges—fromVersion,toVersion, the four counts and the file it read. It is a sibling ofprotocolVersionGap,not a widening of it: that key means "the platform on disk is outside the range you declared",
and a consumer gating CI on it must not start failing because an ordinary minor shipped exports.
One key, one question. (Note
specVersionGapis spelledprotocolVersionGapin this tree — itwas renamed by #14261, which is still an unreleased changeset; the ruling's clause 3 names the
old spelling.)
4 · Published payload change.
Clause-②: yes (widening), contract-review carrier, changesetcarrying the migration-free declaration, and
content/docs/upgrading.mdxgains What theinstalled artifact tells you, without a second worktree.
Absence is not zero
The section is omitted, loudly, when the previous tarball ships no export snapshot or no
manifest — an empty
releaseis indistinguishable from "this release changed nothing", which isthe misreading the whole section exists to end. The gate derives the same condition from the same
artifacts, accepts that absence, and refuses a section that is present when it could not have
been computed.
specReleaseChangesisnullin exactly those cases.Verification
Real artifacts, not fixtures —
npm packof published 17.3.0,pnpm packof this tree:domain:specseat — this line read 「374 added」 when the PR opened.origin/mainhas since moved the export surface, and 386 is the number measured at the current head1737a24b510from a realnpm packof the published 17.3.0, independently confirmed by the at-tier review at the previous head.os-dev.md:56reserves this body to the PR-open write, so the round named the number and the seat writes it.Ablation, on the real artifact. One export dropped from
release.addedin the packedmanifest (mutation proven on disk: sha256
bc21927e…→3bbff365…), restored undertrap … EXIT INT TERM, hash verified equal after:Gates and tests, exit codes captured by redirect:
pnpm check:release-spec-changes— 15 batteries (roster + floor + verdict handshake), exit 0.The real run needs two published tarballs, so the self-test is what a PR can run; it drives the
same
verifyRelease()the release lane calls.pnpm --filter @objectstack/spec check:generated— all 16 artifacts up to date aftergen:api-surface+gen:export-origins+gen:api-surface-declarations(7 new declarations onthe root entry, 0 removed).
domain:specseat: this read 「all 15」 when the PR opened. The count movedbecause feat(spec): pin every export by its .d.ts declaration text, and retire the 27 signature hashes #18971 (
d8b12fca97c) registeredcheck:api-surface-declarationsas a new generatedartifact family. The gate now prints 「Checking 16 generated artifacts」. ⛔ The 15 was not wrong
when written — the base moved under it.
os-dev.md:56reserves this body to the PR-open write, sothe round named the number and the seat writes it.
pnpm --filter @objectstack/spec exec vitest run --project local src/migrations/migrations.test.ts— 137 passed.
pnpm --filter @objectstack/cli exec vitest run --project unit src/utils/spec-release-changes.test.ts— 6 passed.
pnpm --filter @objectstack/spec typecheck— exit 0.pnpm --filter @objectstack/cli typecheck— exit 0 (after building the runtime closure the CLI's test project reads; its first run reported
only
TS2307 Cannot find modulefor packages this worktree had not built).pnpm check:entry-guard— exit 0 (265 files, 205 exporters inert on import). This one caught areal defect in the new gate: it exports
verifyReleaseand dispatched at the top level, soimporting it would have run it. Fixed with
isEntrypoint.Gate families, derived by
scripts/pm/dispatch-gates.mjsfrom the diff and reconciled with--ran: 153 derived, 147 run (146 exit 0), 4 NOT MEASURED (check:i18n,check:i18n-coverage,check:i18n-walk-parity,check:dual-build-cjs-loads— each exits 3,PREREQUISITE NOT MET, wanting a full repo build), 2 unrun (
check:pm-dispatch-gates, whoseown header forbids running it in an agent container's foreground;
check:type-check-debt,repo-wide
tsc, killed by this runner's timeout).check:spec-changesandcheck:skill-examplesdeserve a word each: the first is green, which is the determinismhalf of clause 1; the second exits 1 saying "packages/client-react/dist holds no .d.ts — the
package is not built", so it is NOT MEASURED, not a finding. None of the six touches this
diff's subject, and CI builds fresh.
Acceptance notes
release.converted. Between 17.3.0 and 17.4.0perMajor[16→17].convertedwent 58 → 57:field-required-notnull-explicitleft the publishedchain. The registry records that as a deliberate withdrawal (
⛔ WITHDRAWN — there is deliberately NO field-required-notnull-explicit), so this is documented behaviour, not drift.The section reports entries a release added, which is the four arrays ADR-0087 D4 names;
an id that disappeared is visible only by comparing two published manifests, and the code says
so rather than implying otherwise.
claim to close it. Of the 65 new
[REMOVED]prescriptions between 17.3.0 and 17.4.0, exactlyone names 17.4.0 as the retiring release.
release.removedanswers "which exports left in thisrelease" exactly; it says nothing about which prescriptions were written in it, because the
prescription text carries no retirement version in data. A consumer still cannot tell
「retired in X」 from 「prescription written in Y」 from the tombstones alone.
toMajor: 17" holds insideperMajor[16→17]; theaggregate carries
toMajorvalues 11, 13, 14, 15 and 17. The substance — no resolution finerthan a major — reproduced.
Generated by Claude Code
Generated by Claude Code