Skip to content

Commit 14a762f

Browse files
os-elon-muskclaude
andauthored
fix(spec): spec-changes.json's aggregate export diff declares the release pair it really spans (#19115)
Fixes #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 #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 #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 #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` — #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 #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 #19095, #19090, #19084, #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 #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 #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 #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>
1 parent 956e010 commit 14a762f

8 files changed

Lines changed: 642 additions & 32 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `spec-changes.json`'s aggregate export diff declares the release pair it really spans (#18978)
6+
7+
Clause-②: yes (widening) — one new OPTIONAL key on a published artifact (`aggregate.surfaceScope`)
8+
and one new optional field on `SpecChangesSchema`. Nothing is renamed, retired or reshaped: the
9+
schema still ACCEPTS a record without it, every existing key keeps its spelling and meaning, and
10+
`perMajor` and the `release` section are byte-identical. Contract-review tier.
11+
12+
`aggregate.added` / `aggregate.removed` are not registry-derived. A release-time api-surface diff
13+
fills them by comparing the artifact being published against the previously **published** one, so
14+
they span **one release** — while the record they sit in is keyed by protocol major (`from: 10,
15+
to: 17`) and every entry carries only `since: 17` / `removedIn: 17`, with
16+
`perMajor[16 → 17].added` at `0` beside it. Nothing in the file distinguished one minor's slice
17+
from the whole major-boundary delta.
18+
19+
Measured on the published `@objectstack/spec@17.4.0` Release asset: `aggregate.added` = **225**,
20+
`aggregate.removed` = **51**, every entry `since`/`removedIn` = 17 — and set-identical to a
21+
recomputed `17.3.0 → 17.4.0` diff of the two tarballs' own `api-surface/` snapshots. It was the
22+
minor's delta wearing a major's label.
23+
24+
**What ships now.** A record whose export arrays are non-empty carries the version pair they were
25+
diffed between:
26+
27+
```bash
28+
jq '.aggregate | {from, to, surfaceScope, added: (.added | length), removed: (.removed | length)}' \
29+
node_modules/@objectstack/spec/spec-changes.json
30+
```
31+
32+
- `surfaceScope: { fromVersion, toVersion }` present ⇒ `added`/`removed` span exactly that
33+
published-version pair. ⛔ They are **not** the `from` → `to` major delta, and never were.
34+
- `surfaceScope` absent ⇒ the record carries no export diff at all and `added`/`removed` are
35+
empty. ⛔ Read that as "this record does not say", never as "nothing was added between `from`
36+
and `to`" — the same rule the `release` section already states for itself.
37+
- `from` / `to` still answer the major-boundary question for `converted` / `migrated`, which are
38+
registry-derived and unaffected.
39+
40+
**Refused at the producer and at the publish gate, in both directions.** The generator reads the
41+
previous version off the previous artifact's own `package.json`, omits the arrays loudly when it
42+
cannot read one, and refuses outright to write a non-empty unlabelled array.
43+
`scripts/check-release-spec-changes.mjs` — which until now checked the `release` section and not
44+
the aggregate — recomputes the aggregate's claim from the two tarballs and refuses an absent,
45+
mislabelled or untrue scope. Its self-test roster grows from 15 batteries to 23.
46+
47+
**Nothing previously honest moved.** The committed registry-only projection and every `perMajor`
48+
record carry no new key at all; the committed `spec-changes.json` changes on its `$comment` line
49+
and nowhere else. The published schema is deliberately not narrowed — every manifest published so
50+
far carries an unscoped diff and must keep parsing.

‎content/docs/upgrading.mdx‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,27 @@ jq '.release | {fromVersion, toVersion,
335335
named — `"./ai: AgentSchema (const)"`, the entry point followed by the export
336336
and its kind. `converted` and `migrated` are the ADR-0087 conversions and
337337
semantic migrations first registered in that release. The same file's
338-
`aggregate` and `perMajor` records are unchanged and still answer the
339-
major-boundary question.
338+
`perMajor` records are unchanged and still answer the major-boundary question,
339+
and so does `aggregate` — for its `converted` and `migrated`, which are derived
340+
from the ADR-0087 registries across the whole `from` → `to` range.
341+
342+
⛔ **But not for `aggregate.added` / `aggregate.removed`.** Those come from the
343+
same one-release export diff as the section above, not from the major range the
344+
record is keyed by, so when they are filled the `aggregate` record carries a
345+
`surfaceScope` naming the exact pair they span:
346+
347+
```bash
348+
jq '.aggregate | {from, to, surfaceScope,
349+
added: (.added | length), removed: (.removed | length)}' \
350+
node_modules/@objectstack/spec/spec-changes.json
351+
```
352+
353+
`surfaceScope` absent means that record claims no export diff at all and its
354+
`added` / `removed` are empty — the registry-only shape. ⛔ Read that as "this
355+
record does not say", never as "nothing was added between `from` and `to`",
356+
which is the same rule the `release` section states for itself below. A release
357+
whose `aggregate` arrays disagree with the two published tarballs, or carry no
358+
`surfaceScope`, does not publish.
340359

341360
The `os` CLI reads the same section, so a CI job does not have to know the file
342361
exists:

‎packages/spec/api-surface-declarations/root.txt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44929,6 +44929,10 @@ declare const SpecChangesSchema: z.ZodObject<{
4492944929
removedIn: z.ZodNumber;
4493044930
replacement: z.ZodOptional<z.ZodString>;
4493144931
}, z.core.$strip>>;
44932+
surfaceScope: z.ZodOptional<z.ZodObject<{
44933+
fromVersion: z.ZodString;
44934+
toVersion: z.ZodString;
44935+
}, z.core.$strip>>;
4493244936
}, z.core.$strip>;
4493344937

4493444938
// ── SpecConverted (type) ──
@@ -45036,6 +45040,8 @@ type StoredConversionOptions = Omit<ApplyConversionsOptions, 'includeRetired'>;
4503645040
interface SurfaceDiff {
4503745041
added?: SpecSurfaceAdd[];
4503845042
removed?: SpecSurfaceRemove[];
45043+
/** The published-version pair `added`/`removed` were diffed between. */
45044+
scope?: SpecSurfaceScope;
4503945045
}
4504045046

4504145047
// ── TemplateExpressionInputSchema (const) ──

‎packages/spec/scripts/build-spec-changes.ts‎

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,19 @@
1818
* Release-time surface join: `--previous-surface <path>` diffs the current
1919
* committed export surface against a previously *published* one (both ship in
2020
* the npm artifact from protocol 15 on) and fills the `added[]`/`removed[]`
21-
* arrays of the aggregate record, attributed to the current major. The Release
22-
* workflow runs this against the last published spec tarball and attaches the
23-
* result to the GitHub Release; the committed copy keeps `added`/`removed`
24-
* empty (registry-derived content only) so it stays deterministic.
21+
* arrays of the aggregate record. The Release workflow runs this against the
22+
* last published spec tarball and attaches the result to the GitHub Release; the
23+
* committed copy keeps `added`/`removed` empty (registry-derived content only)
24+
* so it stays deterministic.
25+
*
26+
* ⚠️ That diff is ONE RELEASE wide while the aggregate record is keyed by
27+
* protocol MAJOR (`from: 10, to: 17`), so the arrays ship with
28+
* `surfaceScope: { fromVersion, toVersion }` naming the pair they really span.
29+
* Without it a consumer read one minor's 225-export slice as the whole 10 → 17
30+
* delta — with `perMajor[16 → 17].added` sitting at `0` beside it and no field
31+
* distinguishing the two. The previous version is read off the previous
32+
* artifact's own `package.json`; when it cannot be read the arrays are OMITTED,
33+
* loudly, and a non-empty unlabelled array is refused outright.
2534
*
2635
* `<path>` is whichever shape that published tarball carried: the `api-surface/`
2736
* directory from #5837 on, or the single `api-surface.json` before it. Reading
@@ -68,9 +77,11 @@ import {
6877
composeSpecChanges,
6978
SpecChangesSchema,
7079
SpecReleaseChangesSchema,
80+
surfaceScopeProblem,
7181
type SpecReleaseChanges,
7282
type SpecSurfaceAdd,
7383
type SpecSurfaceRemove,
84+
type SpecSurfaceScope,
7485
} from '../src/migrations/spec-changes';
7586
import { API_SURFACE_DIR_NAME, readApiSurfaceFrom } from './lib/sharded-artifacts';
7687

@@ -104,6 +115,39 @@ const PREV_SURFACE = PREV_PACKAGE
104115
? process.argv[prevSurfaceIdx + 1]
105116
: undefined;
106117

118+
/**
119+
* The `version` of the unpacked published tarball an export snapshot came out
120+
* of, or `null` when the snapshot's path does not sit inside one.
121+
*
122+
* `--previous-package` points at the `package/` root, so the manifest is right
123+
* there; `--previous-surface` points at the snapshot itself (`api-surface/` or
124+
* `api-surface.json`), whose parent is that same root in every shape the release
125+
* lane has ever produced. Read, never transcribed — the same discipline
126+
* {@link previousRelease} already applies to the registry ids.
127+
*/
128+
function publishedVersionAt(pkgDir: string): string | null {
129+
const pkgPath = resolve(pkgDir, 'package.json');
130+
if (!existsSync(pkgPath)) return null;
131+
const version = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }).version;
132+
return typeof version === 'string' && version.length > 0 ? version : null;
133+
}
134+
135+
/**
136+
* The version pair the aggregate's `added`/`removed` really span, or `null` when
137+
* the previous release's version cannot be read off the inputs.
138+
*
139+
* ⛔ `null` is not "omit the label" — the caller then omits the ARRAYS, loudly.
140+
* An unlabelled export diff under a major-keyed record is the defect this whole
141+
* field exists to end, so producing it would be worse than producing nothing.
142+
*/
143+
function surfaceScope(): SpecSurfaceScope | null {
144+
const root = PREV_PACKAGE ?? (PREV_SURFACE ? resolve(PREV_SURFACE, '..') : undefined);
145+
if (!root) return null;
146+
const fromVersion = publishedVersionAt(root);
147+
if (!fromVersion) return null;
148+
return { fromVersion, toVersion: THIS_VERSION };
149+
}
150+
107151
/** Flatten an export surface ({ entry: ["name (kind)", …] }) into one set. */
108152
function flattenSurface(path: string): Set<string> {
109153
const doc = readApiSurfaceFrom(path);
@@ -186,7 +230,23 @@ function buildReleaseSection(current: ReturnType<typeof composeSpecChanges>): Sp
186230
}
187231

188232
function build(): string {
189-
const surfaceDiff = PREV_SURFACE ? diffSurfaces(PREV_SURFACE) : {};
233+
// The export diff spans ONE RELEASE, so it ships only with the version pair
234+
// that says so. No readable previous version ⇒ no arrays, and the reason is
235+
// printed: an unlabelled slice under the MAJOR-keyed aggregate record is read
236+
// as the whole major-boundary delta, which is strictly worse than an empty
237+
// one — the same call `buildReleaseSection` makes for the same reason.
238+
const scope = surfaceScope();
239+
let surfaceDiff: ReturnType<typeof diffSurfaces> | { scope?: SpecSurfaceScope } = {};
240+
if (PREV_SURFACE && scope) {
241+
surfaceDiff = { ...diffSurfaces(PREV_SURFACE), scope };
242+
} else if (PREV_SURFACE) {
243+
console.error(
244+
`No aggregate export diff: the previous artifact at ${PREV_PACKAGE ?? PREV_SURFACE} carries no ` +
245+
'readable package.json, so the version pair the diff spans cannot be read. Omitting ' +
246+
'`added`/`removed` — an unlabelled one-release slice under the major-keyed aggregate record ' +
247+
'reads as the whole from → to delta.',
248+
);
249+
}
190250

191251
// Per-major records compose (ADR-0087 D4): any tool can fold them into a
192252
// single from→to view. The aggregate is that fold, precomputed.
@@ -197,13 +257,24 @@ function build(): string {
197257
const aggregate = SpecChangesSchema.parse(
198258
composeSpecChanges(MIGRATION_SUPPORT_FLOOR, PROTOCOL_MAJOR, surfaceDiff),
199259
);
260+
const problem = surfaceScopeProblem(aggregate);
261+
if (problem) {
262+
console.error(`Refusing to write ${SNAPSHOT}: ${problem}`);
263+
process.exit(1);
264+
}
200265
const release = buildReleaseSection(aggregate);
201266

202267
const doc = {
203268
$comment:
204269
'GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. ' +
205270
'A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes ' +
206271
'tool derive from this same data. ' +
272+
'A record\'s `added`/`removed` are NOT at its `from` → `to` MAJOR resolution: they come from an ' +
273+
'api-surface diff against the previously PUBLISHED artifact, so they span ONE RELEASE. When they are ' +
274+
'non-empty the record carries `surfaceScope: { fromVersion, toVersion }` naming exactly that pair, and a ' +
275+
'release whose arrays disagree with the two tarballs — or carry no `surfaceScope` — does not publish. ' +
276+
'Absent `surfaceScope` means the record carries no export diff at all (`added`/`removed` empty), never ' +
277+
'"nothing was added between from and to". ' +
207278
'When a `release` section is present, its four ADR-0087 D4 arrays report what that release ADDED: ' +
208279
'`added`/`removed` are the export-surface diff of the two published tarballs, and `converted`/`migrated` ' +
209280
'are the D2/D3 ids FIRST REGISTERED in it. An id that LEFT the published chain between the two releases ' +

‎packages/spec/spec-changes.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$comment": "GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes tool derive from this same data. When a `release` section is present, its four ADR-0087 D4 arrays report what that release ADDED: `added`/`removed` are the export-surface diff of the two published tarballs, and `converted`/`migrated` are the D2/D3 ids FIRST REGISTERED in it. An id that LEFT the published chain between the two releases is reported in none of them — `converted: []` means \"this release registered none\", never \"none was withdrawn\"; a withdrawal is visible only by comparing two published manifests.",
2+
"$comment": "GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes tool derive from this same data. A record's `added`/`removed` are NOT at its `from` → `to` MAJOR resolution: they come from an api-surface diff against the previously PUBLISHED artifact, so they span ONE RELEASE. When they are non-empty the record carries `surfaceScope: { fromVersion, toVersion }` naming exactly that pair, and a release whose arrays disagree with the two tarballs — or carry no `surfaceScope` — does not publish. Absent `surfaceScope` means the record carries no export diff at all (`added`/`removed` empty), never \"nothing was added between from and to\". When a `release` section is present, its four ADR-0087 D4 arrays report what that release ADDED: `added`/`removed` are the export-surface diff of the two published tarballs, and `converted`/`migrated` are the D2/D3 ids FIRST REGISTERED in it. An id that LEFT the published chain between the two releases is reported in none of them — `converted: []` means \"this release registered none\", never \"none was withdrawn\"; a withdrawal is visible only by comparing two published manifests.",
33
"protocolVersion": "17.0.0",
44
"supportFloor": 10,
55
"migrateCommand": "objectstack migrate meta --from <N> (N >= 10)",

0 commit comments

Comments
 (0)