Skip to content

feat(spec): ship a per-release section in spec-changes.json, verified against both tarballs - #18889

Merged
os-litant merged 10 commits into
mainfrom
claude/issue-17080-per-release-spec-changes
Sep 18, 2026
Merged

os-litant merged 10 commits into
mainfrom
claude/issue-17080-per-release-spec-changes

Conversation

@os-litant

@os-litant os-litant commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

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-surface
row convention (ENTRY: NAME (KIND) — entry point, export name, export kind — the spelling
build-spec-changes.ts flattens to):

17.3.0 17.4.0
export-surface rows 5259 5433
true delta — 225 added, 51 removed
spec-changes.json → aggregate.added / .removed 0 / 0 0 / 0
protocolVersion 17.0.0 17.0.0
perMajor[16→17] converted 58, migrated 77 converted 57, migrated 77
[REMOVED] prescriptions in json-schema/** 156 221

Every figure the card reports reproduced. The shipped manifest answered 0 / 0 over a release
that moved 276 exports, and a consumer reading semver sees a minor.

What lands

1 · A per-release section, shipped in the tarball. spec-changes.json gains release —
fromVersion → toVersion at package-version resolution, with added / removed (the exports
that arrived and left, each named) and converted / migrated (the ADR-0087 D2/D3 entries first
registered in that release). Generated at publish time only, from the previously published
tarball: the committed copy stays the deterministic registry projection and check:spec-changes
is 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.mjs deliberately does not import the generator's
flattening, 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 --json reads the same data. New key specReleaseChanges — fromVersion,
toVersion, the four counts and the file it read. It is a sibling of protocolVersionGap,
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 specVersionGap is spelled protocolVersionGap in this tree — it
was 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, changeset
carrying the migration-free declaration, and content/docs/upgrading.mdx gains What the
installed 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 release is indistinguishable from "this release changed nothing", which is
the 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. specReleaseChanges is null in exactly those cases.

Verification

Real artifacts, not fixtures — npm pack of published 17.3.0, pnpm pack of this tree:

generate --previous-package (the unpacked published 17.3.0)  ->  pack  ->  gate
✓ release 17.3.0 → 17.4.0 verified against both tarballs: 386 added, 302 removed, 0 converted, 0 migrated.

⚠️ Corrected by the domain:spec seat — this line read 「374 added」 when the PR opened. origin/main has since moved the export surface, and 386 is the number measured at the current head 1737a24b510 from a real npm pack of the published 17.3.0, independently confirmed by the at-tier review at the previous head. os-dev.md:56 reserves 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.added in the packed
manifest (mutation proven on disk: sha256 bc21927e… → 3bbff365…), restored under
trap … EXIT INT TERM, hash verified equal after:

exit 1
✗ the per-release section of spec-changes.json disagrees with the two tarballs (ADR-0087 D4).
  A wrong change file is worse than none — a consumer gates its upgrade on this data.

  release.added: 1 export(s) the two tarballs show as added and the section OMITS:
        ./ai: BUILD_PROGRESS_FRAME_TYPE (const)
  Regenerate with: pnpm --filter @objectstack/spec exec tsx scripts/build-spec-changes.ts
  --previous-package PREV_PACKAGE_DIR      [the real line spells the placeholder in angle brackets]

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 after
    gen:api-surface + gen:export-origins + gen:api-surface-declarations (7 new declarations on
    the root entry, 0 removed).

    ⚠️ Corrected by the domain:spec seat: this read 「all 15」 when the PR opened. The count moved
    because feat(spec): pin every export by its .d.ts declaration text, and retire the 27 signature hashes #18971 (d8b12fca97c) registered check:api-surface-declarations as a new generated
    artifact family. The gate now prints 「Checking 16 generated artifacts」. ⛔ The 15 was not wrong
    when written — the base moved under it. os-dev.md:56 reserves this body to the PR-open write, so
    the 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 module for packages this worktree had not built).

  • pnpm check:entry-guard — exit 0 (265 files, 205 exporters inert on import). This one caught a
    real defect in the new gate: it exports verifyRelease and dispatched at the top level, so
    importing it would have run it. Fixed with isEntrypoint.

  • Gate families, derived by scripts/pm/dispatch-gates.mjs from 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, whose
    own 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-changes and
    check:skill-examples deserve a word each: the first is green, which is the determinism
    half 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

  • A withdrawn conversion is not reported by release.converted. Between 17.3.0 and 17.4.0
    perMajor[16→17].converted went 58 → 57: field-required-notnull-explicit left the published
    chain. 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.
  • The tombstone trap the card names is not closed by this change, and this change does not
    claim to close it.
    Of the 65 new [REMOVED] prescriptions between 17.3.0 and 17.4.0, exactly
    one names 17.4.0 as the retiring release. release.removed answers "which exports left in this
    release" 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.
  • The card's "every conversion entry carries toMajor: 17" holds inside perMajor[16→17]; the
    aggregate carries toMajor values 11, 13, 14, 15 and 17. The substance — no resolution finer
    than a major — reproduced.

Generated by Claude Code


Generated by Claude Code

`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>
@github-actions github-actions Bot added size/xl ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 18, 2026
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/spec, touching 20 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/root.txt, packages/spec/api-surface/root.json, packages/spec/export-origins/root.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via diffItem (sdk, the bare tail of client method meta.diffItem, bound to GET /api/v1/meta/:type/:name/diff), meta.diffItem (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/diff, selected by route anchor /:type/:name/diff), meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/kernel/contracts/metadata-service.mdx (via /:type/:name/rollback (route, bridged from symbol toVersion — its route source's handler names it))
  • content/docs/upgrading.mdx (via fromVersion (symbol, a field of const object SpecReleaseChangesSchema; a field of interface ReleaseSection; a field of interface SpecReleaseChanges), toVersion (symbol, a field of const object SpecReleaseChangesSchema; a field of interface ReleaseSection; a field of interface SpecReleaseChanges))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via /:type/:name/diff (route, bridged from symbol fromVersion — its route source's handler names it; bridged from symbol toVersion — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/root.txt, packages/spec/api-surface/root.json, packages/spec/export-origins/root.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 49 pages)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2767af8e8354511f9c82ce402b147ad12c512819 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c38608877ccc08fc0e719a6fcd7e9dcc312bec56 — the merge of head 2733f3f2495146867eba94dff1d0fce6b7dc14df into base 2767af8e8354511f9c82ce402b147ad12c512819, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2767af8e8354511f9c82ce402b147ad12c512819 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 100/100 CONTRACT_REVIEW_TIER
Head-sha: 4f1724d33b9d0537248bfc7f726b03745c8fe1a3

⭐ 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 judgments

1. 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 17.3.0 and 17.4.0 tarballs, generated, packed, and ran the gate green (「release 17.3.0 → 17.4.0 verified against both tarballs: 386 added, 302 removed, 0 converted, 0 migrated」), then broke it five ways, each with a sha256 proof of the mutation on disk and a restore proven byte-equal:

ablation the gate said
drop one export from release.added 「1 export(s) the two tarballs show as added and the section OMITS: ./ai: BUILD_PROGRESS_FRAME_TYPE (const)」
invent an export in added 「the section CLAIMS but the two tarballs do not show as added: ./ai: Ghost (const)」
drop one from removed named, 「OMITS」
wrong fromVersion 「generated against a different release」
delete the section 「ships no release section, but one is owed」

⭐ 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. files[] lists it, the pack carried it, and the committed copy has zero release keys. Lit: with the section in the tree check:spec-changes → exit 1 「stale」; dark: after restore → exit 0. ⇒ the committed copy cannot carry the section without going red.

3. Generation really is publish-time only. Established by sweeping every workspace package.json for a build/prepack/prepublishOnly that invokes the generator (none), reading turbo's build outputs (only dist/**, json-schema/**), confirming neither release-lane script contains git checkout|stash|clean|reset|restore, and finding that @changesets/cli passes --no-git-checks, so the dirty tree packs.

4. The card's figures reproduced, and the one that did NOT is checked. 17.3.0 → 17.4.0: perMajor[16→17].converted 58→57, [REMOVED] prescriptions 156→221, export rows 5259→5433, true delta 225 added / 51 removed, no release key in either. The dev's own caveat holds: 「every conversion entry carries toMajor 17」 is true only inside perMajor[16→17] — the aggregate carries {11,13,14,15,17} — and the substance (nothing finer than a major) stands.

② Semver level

minor on @objectstack/spec and @objectstack/cli, as declared. Purely additive: an optional section, a new --json key, 7 new root exports; nothing renamed, retired or reshaped.

③ Boundary flags — PASS, and three of these are worth the next reader's time

  1. ⚠️ Withdrawals are invisible in the shipped artifact. The section carries ADR-0087 D4's four arrays, and the boundary 「an id that disappeared is deliberately NOT reported」 lives in a JSDoc and this PR's body — ⛔ not in the shipped spec-changes.json $comment and ⛔ not on the consumer docs page. Measured consequence for the very window this card is about: a consumer reads release.converted: [] as 「no conversion change」 while field-required-notnull-explicit actually left the chain.
  2. ⚠️ Two copies of the delta ship and only one is gated. At publish time aggregate.added / aggregate.removed are also filled with the same surfaces, under a from: 10, to: 17 record, while perMajor[16→17].added/removed stay 0/0. The gate verifies release.* only ⇒ a consumer reading aggregate.added reads a one-release slice labelled at major resolution.
  3. 「Previous」 is the last version published on npm, prereleases included. Harmless today (order == semver order across all 166 versions), but ADR-0087 D6 requires an RC cycle before every major, so the next major's section will read 18.0.0-rc.N → 18.0.0, ⛔ not 17.x → 18.0.0.
  4. The repair lane's backfill step runs --prepare + --attach with no --verify — Release page only, never the tarball.
  5. Clause ② is right and so is the arm: additive published surface ⇒ 「扩大公开面」 per SKILL.md:476, ⛔ not the :515 pull-back case. Clause-②: yes (widening) is line-anchored in both the PR body and the changeset; yes takes at least minor ✓; one arm; no governed surface in the 18-file list.
  6. The PR body's 「374 added」 is stale against this head (386 after the origin/main merge moved the surface). The seat owns that line and will correct it.

⭐ The specVersionGap question the seat left open — what the measurement supports, ⛔ not a ruling

Ruling clause 3 names specVersionGap, which has no live referent: #14261 renamed it protocolVersionGap and that changeset is still unconsumed in the tree, while published @objectstack/cli@17.4.0 has 2 hits for the old name and 0 for the new. The dev added a sibling, specReleaseChanges, rather than widening either.

The existing key's assertion is pinned twice — its header (「fires precisely when boot would refuse the app」) and the rename changeset's migration snippet, which tells consumers to gate on if (payload.protocolVersionGap). ⇒ making it non-null on a minor delta would make a key documented as 「boot would refuse」 fire for every app boot accepts, and would flip the boolean gate consumers were told to write.

The sibling reads the same release section (clause 3's operative words), is null when absent and never zeros. ⇒ the measurement supports the sibling. The naming question — the ruling says one thing and the tree has neither — is the seat's to settle, and is recorded here rather than decided by the round.

NOT MEASURED

The release lane itself (no seat may trigger release.yml); its local half was reproduced instead. The built dist/*.d.ts — whether the JSDoc boundary note reaches a consumer's declarations. check:generated / check:api-surface / check:entry-guard / check:adr-0087-registration locally (need dist or a full closure) — taken from CI's green at this head. Whether Validate Package Dependencies is branch-protection-required. The repair lane's checkout ref. objectstack-ai/ats — out of scope, not reached. Full local sweeps — CI's; only the two targeted suites ran here.

⚠️ Landing blocker, and it corrects an action the seat took

Validate Package Dependencies is still red at this head — for a reason outside this PR: OSV flagged devalue@5.9.0 on main itself. That is fixed and merged (5e0a1b9e023, 07:36:02Z).

⭐ The seat re-ran the failed job at 08:0xZ expecting the new base to clear it. That was wrong, and the round measured why: attempt 2 reuses the original merge ref, so its log still shows devalue 5.9.0 from a lockfile predating the fix. ⇒ a re-run cannot clear this; only a new commit merging origin/main will. A merge round is dispatched.

⚠️ That merge will MOVE THE HEAD, so this PASS will no longer name the landing head and a merge-delta review is owed before landing. ⭐ Lesson recorded: when the base has moved, merge main BEFORE dispatching the at-tier review, ⛔ not after.

Implemented-by: claude/issue-17080-per-release-spec-changes
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

…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>

Copy link
Copy Markdown
Collaborator Author

✅ 达档合约复核 PASS —— 合并增量,点名落地 head 1737a24b510f1086af658c1351ad53ec85e1be7e

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-18T09:44Z。

本轮是合并增量复核:4f1724d33b9 上的 PASS 记录 5727299294 判过的一律不重开,范围只有合并提交 951f75c1f7a 加一笔 docs。⭐ 先并 main 再派复核,⛔ 不是先复核再并 —— 否则 PASS 不再点名落地 head,要再买一次复核。

档位核验(本席自取,⛔ 不采信复核自述)

复核轮 JSONL 抄本里逐条消息的 harness 盖章 message.model:claude-fable-5-1 89 / 89,off-tier 0,抄本 222 行。CONTRACT_REVIEW_TIER 即该档 ⇒ 达档。⛔ 未用派发时的 model 参数(那是请求,不是服务读数)。

复核自己去证的几条(摘,全文见下)

断言 复核的取法
合并没吞任何一侧 主侧 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 语境错:

  1. 更正按语被写进了 code fence 里面(围栏 68→73,按语在 72)—— 它会以等宽字面量渲染,读起来像是闸门自己打印的一部分;
  2. 更正把围栏内那行改成了 **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-side git diff --name-only 89c6ec52b56 951f75c1f7a = 18 paths. comm -12 intersection = EMPTY (lit control: two overlapping lists hit b). ⇒ 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 = 916e598cb16 and packages/spec/src/migrations/registry.ts = 09ff2453556 at 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:generated run locally at the head after a real pnpm --filter @objectstack/spec build (both under os-verify-lock.sh, VERDICT command-exit 0, held 175s / 75s, no queue-timeout): 「✓ All 15 generated artifacts are up to date」 including check:spec-changes, check:migration-registry, check:api-surface, check:export-origins. This is stronger than the PASS, which took check:generated from CI.
  • The devalue remedy really arrived through the base: git merge-base --is-ancestor 5e0a1b9e023 951f75c1f7a exit 0 (lit: 89c6ec52b56 exit 0; dark: the newer main tip 873e0e8e270 exit 1); head's pnpm-workspace.yaml carries 'devalue@<6.0.0': '^5.9.2' and the lockfile resolves devalue@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

  1. Clause ② does not move. Clause-②: yes (widening) stands for the PR's own additive published surface (optional release section, specReleaseChanges key, 7 new root exports) — 「扩大公开面」, ⛔ not the SKILL.md:515 pull-back case. The delta itself adds nothing to that: check-widening-tells --declaration no over the docs commit alone (951f75c1f7a..head) = 0 tells, exit 0; over the whole delta (4f1724d33b9..head) the single tell is packages/spec/src/ui/view.zod.ts:1777 (style on ListMapConfigSchema), which is main's feat(spec): declare style on ListMapConfigSchema and point object-map.map back 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 own release schema and exports.
  2. The PR now edits the committed spec-changes.json (19 files, not the 18 the PASS counted). Only the $comment value 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.
  3. 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) : {} and composeSpecChanges(MIGRATION_SUPPORT_FLOOR, PROTOCOL_MAJOR, surfaceDiff) are identical at merge-base (lines 91/100) and head (189/198), and PREV_SURFACE is set whenever --previous-package is. At merge-base the lane already ran the generator with --previous-surface post-publish (release.yml:920), so the Release asset already carried this. What is new is that --prepare runs before changeset publish and writes into the tree that is packed: in my packed head artifact aggregate.added = 386 entries labelled since: 17 and aggregate.removed = 302 labelled removedIn: 17, name-for-name equal to release.added/removed, under from: 10, to: 17, while perMajor[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; since is 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 gated release section precedes it and the docs route a consumer to it; and the previous tarball carried aggregate.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 reaches node_modules, and the $comment honesty fix says nothing about the aggregate slice where it could have in one sentence. Remedy candidates for that card: omit the surface diff from aggregate at publish time (keep it only in release), or state the slice's true bounds in data.
  4. 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.
  5. PR body drift the seat owns: 「374 added」 → 386; 302 removed absent from the body's verification line. Nothing else in the body, the PASS record or the merge round's report 5727978261 was 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 Dependencies success) 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/cli unit/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 CI Test Core (1..6/6) and Type Check · workspace success at the head.
  • The repo-wide gate farm locally (the 125 UNRUN families the round declared) — Lint & Repo Gates success at the head is CI's reading, not mine; locally I ran check:generated (15), the release-gate self-test (15 batteries), check-widening-tells and check-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 (873e0e8e270 is 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/ (worktree wt at the head, prev/ = unpacked 17.3.0, packed/ = my pack, logs); the worktree is registered in /home/user/objectstack/.git and can be removed with git worktree remove.

Implemented-by: claude/issue-17080-per-release-spec-changes
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 18, 2026 09:45
@os-litant
os-litant enabled auto-merge September 18, 2026 09:45
@os-litant
os-litant added this pull request to the merge queue Sep 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 18, 2026
…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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2733f3f2495146867eba94dff1d0fce6b7dc14df

Merge-delta review only. Everything the PASS at 4f1724d33b9 (record 5727299294) and the merge-delta PASS at 1737a24b510 (record 5728233285) judged is closed and not reopened. Scope = 1737a24b510 → 2733f3f2495146867eba94dff1d0fce6b7dc14df: merge commit 09ae986603f (parents exactly 1737a24b510f1086af658c1351ad53ec85e1be7e + f347c793e16322a4befc77651d1ab8760bf36874, git log --format=%p) plus regeneration commit 2733f3f2495. Clone deepened by 200 before any negative was trusted (.git/shallow 12 → 11 entries); merge-base(1737a24b510, f347c793e16) = 89c6ec52b56 before and after deepening; the window 89c6ec52b56..f347c793e16 is 10 commits, none in .git/shallow, and 89c6ec52b56 is an ancestor of both parents (merge-base --is-ancestor exit 0 both). Readings taken 2026-09-18T10:58Z–11:12Z against the fetched refs, never the shared checkout's working tree.

① Derived judgments — each claim of the round, re-measured

1. Merge was mechanical; no hand resolution; the MIXED paths arrived from main untouched — TRUE, but the round's "clean" needs one correction of instrument.

  • git merge-tree --write-tree 1737a24b510 f347c793e16 in the driver-registered clone = tree 56b011c172d459197a4d1fbfa736b3b8ed201dc3; the committed merge tree 09ae986603f^{tree} = the same hash; git diff --stat between them = EMPTY. The same probe from a bare --shared clone with NO merge.os-regen.driver registered (PROBE.git, per AGENTS.md §11) = exit 0, the same tree 56b011c172d…. ⇒ what was committed is byte-for-byte what git produces unattended, with and without the driver.
  • ⚠️ Unlike the previous merge-delta, the path sets are NOT disjoint: branch-side 89c6ec52b56..1737a24b510 = 19 paths, main-side 89c6ec52b56..f347c793e16 = 81 paths, comm -12 = .github/workflows/lint.yml and package.json (lit control: overlapping lists hit b). git diff-tree -c 09ae986603f therefore lists exactly those two, so the prior record's "combined diff empty" instrument would have flagged this round. The correct reading: both files equal a mechanical 3-way git merge-file of (branch, base, main) byte-for-byte (cmp clean, merge-file exit 0 = no conflict hunks), the PR's hunks survive (lint.yml:956 run: pnpm check:release-spec-changes; root package.json:74 the script) and main's hunks survive (main's removal of check:type-source-resolution — 0 hits at head, control 1 hit at 1737a24b510; main's 33+/31− lint.yml rewrite present). Two-sided automatic text merge, not hand resolution.
  • The two MIXED, hand-resolve-only paths: packages/spec/dropped-refinements.baseline.json = 916e598cb16 at base AND branch, 6622c962124 at main tip AND merge AND head; packages/spec/src/migrations/registry.ts = 09ff2453556 at base AND branch, b2d7a0d58d1 at main tip AND merge AND head. The branch never touched either (git diff --name-only 89c6ec52b56 1737a24b510 over both = EMPTY; control: that diff lists 19 paths); both carry main's blob unchanged. ✓
  • os-regen-pending: the driver runs only when BOTH sides change a routed path. The only both-sides paths are lint.yml and package.json, and git check-attr merge on them reads unspecified (control: api-surface-declarations/root.txt and spec-changes.json read os-regen). ⇒ the driver never ran in this merge, so no deferral could have been recorded. The marker itself lives in the round's $GIT_DIR and is NOT MEASURED; the structural argument is the reading.

2. .gitattributes read from the merged tree — TRUE. Row 147 at head is verbatim packages/spec/api-surface-declarations/** merge=os-regen; the only api-surface rows at head are 146 (api-surface/**) and 147; the api-surface-signatures.json row is gone (control: at 1737a24b510 it is row 150). Row 147 is identical on f347c793e16 and on current main 2767af8e8.

3. Regeneration touched root.txt only, +53/−2, no hand edit — TRUE as far as the tree can show; the "no hand edit" half rests on CI's gate at this sha, ⛔ not on my own regeneration (see NOT MEASURED).

  • git diff --numstat 09ae986603f 2733f3f2495 = exactly 53 2 packages/spec/api-surface-declarations/root.txt; GitHub's file row agrees (additions:53, deletions:2). No other path in the regen commit.
  • The 2 removed lines are the header counts # exported names: 212 / # declarations: 213 → 219 / 220; the 51 other added lines are 7 // ── NAME (kind) ── blocks whose headers are byte-equal to the 7 rows the PR adds to api-surface/root.json (PreviousReleaseRegistries (interface), ReleaseSurfaceDiff (interface), SpecReleaseChanges (type), SpecReleaseChangesSchema (const), SpecReleaseSurface (type), SpecReleaseSurfaceSchema (const), composeReleaseChanges (function)); each is present exactly once in head's root.txt as a declaration header. root.json at head holds 220 rows; the header's 219 names / 220 declarations is the one dual-kind name this entry already carried (212/213 before, +7 both).
  • The queue's own gate output for the merge-group run 35331317491 (job Type Check · consumer gates, 09:55:02Z, queue branch gh-readonly-queue/main/pr-18889-f347c793e16…) names exactly these 7 as + added, 「declaration text changed: 0 removed, 7 added, 0 reshaped」, remedy pnpm --filter @objectstack/spec gen:api-surface-declarations — the regen diff is exactly that remedy's shape, nothing more.
  • 17 shards at head; git ls-tree head vs f347c793e16 differs in ONE blob only (root.txt 7ebf656f6da → 6c83181913b); the other 16 shards are main's blobs untouched. No .txt outside root.txt moved.
  • At head, CI's Type Check · consumer gates (the job that runs check:api-surface-declarations, a regenerate-and-compare against a fresh build) concluded success (id 105569853327, 10:49:26Z) on 2733f3f2495. That is the instrument that distinguishes "regenerated" from "hand-typed to look regenerated"; it is CI's reading pinned to the sha, not mine.

4 / 5. check:generated → 16, check:api-surface-declarations exit 0 (17 entry points, 5343 declarations) — NOT MEASURED by me (lock queue, see below). From the tree: check-generated.ts at head registers check:api-surface-declarations / gen:api-surface-declarations / api-surface-declarations/ (lines 121–123) and its GATED row count equals current main's (19 check: keys both). From CI at head: Lint & Repo Gates success 11:06:15Z, TypeScript Type Check success 10:54:38Z. The numbers 16 / 5343 are the round's; I did not reproduce them.

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' api-surface/ rows, spec-changes.json and package.json version. git diff --stat 1737a24b510 2733f3f2495 -- packages/spec/api-surface/ packages/spec/export-origins/ = EMPTY (control: the same instrument over 89c6ec52b56..head = 2 files, +14), and the delta's only branch-side change is root.txt, which the gate never reads; the gate and generator scripts are unchanged in the delta. The prior record measured 386/302/0/0 on real tarballs at 1737a24b510 on these identical inputs. My own real run (npm pack of published 17.3.0 completed: prev version: 17.3.0, npm-pack-exit=0, shasum 1ca896ffbc05…) is queued behind the lock and had not reached the generator/pack/gate step when this report was forced; see NOT MEASURED for where its logs land.

7. Both merge sides survived — TRUE. 17/17 shards head vs main tip (control: 0 at 1737a24b510); api-surface-signatures.json absent at head and at f347c793e16, PRESENT at 1737a24b510 (control); packages/spec/src/shared/refinement-projection.ts head vs main tip = 0-byte diff (control vs 1737a24b510: 159 insertions); the 7 exports are 7/7 in api-surface/root.json and 7/7 in api-surface-declarations/root.txt; the PR's lint.yml step and package.json script survive main's rewrites of both files (item 1); main's 3 added lines in packages/spec/package.json (gen:/check:api-surface-declarations) are at head lines 266–267. Delta path set 1737a24b510..head (81 paths) equals main's window path set exactly (comm both directions empty); the PR's changeset .changeset/17080-per-release-spec-changes.md is 0-byte-different across the delta (control vs base: +48).

⭐ The dispute — the ROUND is right, the seat's dispatch was wrong for this window. git diff --stat 89c6ec52b56 f347c793e16 -- packages/spec/api-surface/ packages/spec/export-origins/ = EMPTY (exit 0, 0 lines). Two hitting controls on the same instrument: over packages/spec/ in the same window = 37 files (main moved api-surface-declarations/*.txt ×17, deleted api-surface-signatures.json, build-api-surface.ts 98±, registry.ts 47±, …); over the same two paths in the PR's own window 89c6ec52b56..1737a24b510 = 2 files, +14. An independent second instrument agrees: the queue build's own check:api-surface on the merge onto f347c793e16 printed 「@objectstack/spec public API surface unchanged ✓」 at 09:54:59Z, three seconds before the declarations gate failed. What main moved in this window is the declaration-TEXT family and the retired signatures file — not the export listing the 386/302 delta is computed from. The seat's 「main has moved the export surface since」 was true of the PREVIOUS window (625db0e8531..89c6ec52b56, 374 → 386) and was carried forward one window too far; the 386/302 counts were never at risk from this merge.

② Clause ② for the delta

The delta neither widens nor narrows; the PR's standing Clause-②: yes (widening) is unchanged and still the correct and sufficient declaration. The regenerated shard records the .d.ts text of 7 exports the PR had already added and the two prior records had already judged as 「扩大公开面」; it adds no export, no key, no closed-set member, no registration. api-surface-declarations IS in packages/spec files[] (published bytes), so the delta is the published artifact catching up to a surface already declared — SKILL.md:515's pull-back case does not apply (nothing is pulled back), and its 「卡面复述仍是条款②」 clause is why this stays under the existing yes rather than converting to no. Mechanically: check-widening-tells --declaration no over the delta alone = exit 0 with root.txt reported NOT MEASURED — by the #16045 ruling the declaration-text family is a declared non-surface for the tell gate (its own comment lines 355–365), so that exit 0 is evidence about no surface at all; the lit control over the whole PR vs main tip (f347c793e16..head) = exit 4, 14 tells (7 api-surface/root.json rows, migrations/index.ts:39–40, spec-changes.ts ×5), all the PR's own. Semver: minor on @objectstack/spec and @objectstack/cli, changeset byte-identical across the delta, Clause-②: yes (widening) line-anchored in changeset and PR body; check-clause2-carriers --pair 18889 exit 0 (PR body DECLARED yes (arm: widening), governing claim 5725112711).

③ 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, spec-changes.ts, spec-release-changes.ts, release.yml, changeset, api-surface/, export-origins/, committed spec-changes.json — git diff 1737a24b510 head over each = 0 bytes) or is main's own landed work arriving through the base. The one new artifact (root.txt) is the generated description of a surface both records already accepted.

Boundary flags — none blocks

  1. Path-set overlap on lint.yml / package.json (item 1): auto text-merge, proven equal to git merge-file and to driver-less merge-tree; both sides' hunks present. The prior record's diff-tree -c instrument is not sufficient on its own for a merge with overlapping paths — merge-tree equality is.
  2. Governed surface: check-governed-merges.mjs --test over the 20 effective paths (GitHub get_files list = local f347c793e16..head list, IDENTICAL) = exit 0 「NOT governed」; lit control with AGENTS.md = exit 3. Governed Surface Queue Guard success at head.
  3. CI at head, pinned to the sha (read 11:12Z): 47 check runs, 40 success + 7 skipped (Auto Label ×2, Check PR Size ×2, Packed-tarball smoke ×2, Console Pin Gate), 0 failure, 0 in progress; all seven required contexts success (Lint & Repo Gates 11:06:15Z, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). Validate Package Dependencies success. Ruleset required-set not re-read this round (taken from record 5728233285).
  4. Main has moved again: f347c793e16..2767af8e835 = 1 commit, 3 files (a plugin-hono-server fix + its changeset), none under packages/spec, none merge=os-regen; driver-less merge-tree of head onto 2767af8e835 = exit 0 clean. The queue will text-merge; the failure mode this round repaired (a new generated family landing on main) cannot recur from that commit. It CAN recur from any later main commit that registers another artifact family — the queue build is the only instrument for that.
  5. Regen commit trailer reads Co-Authored-By: Claude <noreply@anthropic.com> (harness form) + Claude-Session: — model-free; AGENTS.md exempts the harness-written trailer. Reporting only.
  6. PR body: still carries the seat's corrected 386 added, 302 removed line and 「all 16」 correction; the docs-drift bot recomputed on c38608877cc (head merged onto 2767af8e835) — informational.

NOT MEASURED

  • My own live regeneration, check:api-surface-declarations, check:generated and the real generate→pack→gate run at this head. They are scripted and running as a detached job (run.sh/inner.sh) that was still at lock queue position 1 (holder: another round's @objectstack/lint... build; 395s of budget left) when this report was forced out. npm pack of published 17.3.0 completed (exit 0, version 17.3.0). When it finishes, the readings land at /tmp/claude-0/-home-user/d31c56ec-952d-5a16-945f-6e430b9a6d34/scratchpad/pr-18889/: inner.log (exit codes), status-after-gen.txt + diff-after-gen.txt (EMPTY = regeneration is a no-op = no hand edit, my own instrument), check-decl.log, check-generated.log, gen-release.log (release: {added,removed,converted,migrated}), gate.log (gate-exit=), gate-ctrl.log (lit control, expect exit 1 「owed」), gate-dark.log (--check after restore, expect 0), restore.log (hash before/after + git status --porcelain), run.log ends with DONE. If the lock times out the wrapper exits 99 and lock-run.log says so; nothing ran unlocked.
  • The queue build itself and the release lane (no seat may trigger either).
  • The os-regen-pending marker in the round's own $GIT_DIR (structurally impossible here, item 1).
  • Full local suites/typecheck at head — CI's readings above, pinned to the sha.
  • The ruleset's required-context list — taken from the prior record, not re-read.
  • Scratch left behind, for the seat to remove once the job ends: worktree /home/user/objectstack-pr18889-cr (detached at head, git worktree remove --force), owned refs refs/pm-review/pr-18889-head and refs/pm-review/main-now in /home/user/objectstack/.git, bare probe …/scratchpad/pr-18889/PROBE.git, and the scratchpad logs/tarballs.

Implemented-by: claude/issue-17080-per-release-spec-changes
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS — landing head 2733f3f2495146867eba94dff1d0fce6b7dc14df; the delta is exactly the gate's printed remedy and nothing else, both merge sides are byte-verified, Clause ② is unchanged at yes (widening), and the seat's 「main moved the export surface」 premise is falsified for this window (the round was right).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

✅ 增量复核 PASS —— 落地 head 2733f3f2495146867eba94dff1d0fce6b7dc14df

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-18T11:14Z。

范围只有 1737a24b510 → 2733f3f2495:合并提交 09ae986603f 加重生成提交 2733f3f2495。前两条 PASS(5727299294 / 5728233285)判过的一律不重开。

档位核验(本席自取)

复核轮抄本逐条消息的 harness 盖章 message.model:claude-fable-5-1 99 / 99,off-tier 0。⇒ 达档。

⭐ 这一轮推翻了本席,本席照单认下 —— 而且它把本席错在哪指出来了

本席在派发令里写了「main 已经动了导出面」。假。 实测 git diff --stat 89c6ec52b56 f347c793e16 -- packages/spec/api-surface/ packages/spec/export-origins/ = 空,两个命中对照:同窗口扫 packages/spec/ = 37 个文件;同两条路径扫本 PR 自己的窗口 = 2 个文件 +14。第二件独立仪器同向:队列构建自己的 check:api-surface 在 09:54:59Z 打印「public API surface unchanged ✓」——比声明闸红掉早 3 秒。

⭐ 它还指出本席错在哪一步:那句话对上一个窗口(625db0e8531..89c6ec52b56,374 → 386)是真的,本席把它往前多带了一个窗口。⇒ 这不是笔误,是把一条过期的真话当成当前读数。386/302 从这次合并起就没有过风险。

⭐ 一处仪器升级,值得下一个人知道

这次合并的两侧路径集并不互斥 —— comm -12 命中 .github/workflows/lint.yml 与 package.json。⇒ 上一条记录用的 git diff-tree -c 为空来证「无手工解决」的仪器,在这种合并上会误报。

本轮换了更强的:git merge-tree --write-tree 产出的树与已提交的合并树哈希逐字相等,并且在一个没有注册 merge.os-regen.driver 的裸 --shared 探针克隆里跑出同一个树。⇒ 提交的就是 git 无人干预时产出的东西,有无驱动都一样。两个重叠文件各自等于 git merge-file 的三方自动合并,两侧的改动都在。

⇒ diff-tree -c 为空只在路径集互斥时才够用;不互斥时要用 merge-tree 等值。

落地三前提,逐条

前提 读数
① 点名落地 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

@os-litant
os-litant added this pull request to the merge queue Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2733f3f2495146867eba94dff1d0fce6b7dc14df

Merge-delta review only. Everything the PASS at 4f1724d33b9 (record 5727299294) and the merge-delta PASS at 1737a24b510 (record 5728233285) judged is closed and not reopened. Scope = 1737a24b510 → 2733f3f2495146867eba94dff1d0fce6b7dc14df: merge commit 09ae986603f (parents exactly 1737a24b510f1086af658c1351ad53ec85e1be7e + f347c793e16322a4befc77651d1ab8760bf36874, git log --format=%p) plus regeneration commit 2733f3f2495. Clone deepened by 200 before any negative was trusted (.git/shallow 12 → 11); merge-base(1737a24b510, f347c793e16) = 89c6ec52b56 before and after deepening; the window 89c6ec52b56..f347c793e16 is 10 commits, none in .git/shallow; 89c6ec52b56 is an ancestor of both parents. Readings taken 2026-09-18T10:58Z–11:17Z against fetched refs, never the shared checkout's working tree. Heavy runs went through scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held 294s, waited 288s; nothing ran unlocked except the gate scripts the lock's own status text excludes).

① Derived judgments — each claim of the round, re-measured

1. Merge was mechanical; no hand resolution; the MIXED paths arrived from main untouched — TRUE, with one correction of instrument.

  • git merge-tree --write-tree 1737a24b510 f347c793e16 in the driver-registered clone = tree 56b011c172d459197a4d1fbfa736b3b8ed201dc3 = the committed merge tree 09ae986603f^{tree}; diff between them EMPTY. The same probe from a bare --shared clone with NO merge.os-regen.driver (AGENTS.md §11 method) = exit 0, same tree. ⇒ what was committed is byte-for-byte what git produces unattended, with and without the driver.
  • ⚠️ Unlike the previous merge-delta, the path sets are NOT disjoint: branch-side 89c6ec52b56..1737a24b510 = 19 paths, main-side 89c6ec52b56..f347c793e16 = 81 paths, comm -12 = .github/workflows/lint.yml and package.json (lit control hits b). git diff-tree -c 09ae986603f lists exactly those two, so the prior record's "combined diff empty" instrument would have flagged this round. Correct reading: both files equal a mechanical 3-way git merge-file (branch, base, main) byte-for-byte (cmp clean, exit 0 = no conflict hunks); the PR's hunks survive (lint.yml:956 run: pnpm check:release-spec-changes; root package.json:74) and main's survive (main's removal of check:type-source-resolution: 0 hits at head, control 1 at 1737a24b510; main's 33+/31− lint.yml rewrite present). Two-sided automatic text merge, not hand resolution.
  • MIXED paths: packages/spec/dropped-refinements.baseline.json = 916e598cb16 at base AND branch, 6622c962124 at main tip AND merge AND head; packages/spec/src/migrations/registry.ts = 09ff2453556 at base AND branch, b2d7a0d58d1 at main tip AND merge AND head. Branch never touched either (git diff --name-only 89c6ec52b56 1737a24b510 over both = EMPTY; control: 19 paths). ✓
  • os-regen-pending: the driver runs only when BOTH sides change a routed path; the only both-sides paths read merge: unspecified under git check-attr (control: root.txt and spec-changes.json read os-regen). ⇒ the driver never ran, so no deferral could exist. The marker in the round's $GIT_DIR itself is NOT MEASURED; the structural argument is the reading.

2. .gitattributes read from the merged tree — TRUE. Row 147 at head verbatim packages/spec/api-surface-declarations/** merge=os-regen; only api-surface rows at head are 146 and 147; the api-surface-signatures.json row is gone (control: row 150 at 1737a24b510). Row 147 identical on f347c793e16 and current main 2767af8e8.

3. Regeneration touched root.txt only, +53/−2, no hand edit — TRUE, proven by my own regeneration.

  • git diff --numstat 09ae986603f 2733f3f2495 = exactly 53 2 packages/spec/api-surface-declarations/root.txt; GitHub's file row agrees (additions:53, deletions:2). The 2 removed lines are the header counts 212/213 → 219/220; the 51 other added lines are 7 // ── NAME (kind) ── blocks byte-equal to the 7 rows the PR adds to api-surface/root.json (PreviousReleaseRegistries (interface), ReleaseSurfaceDiff (interface), SpecReleaseChanges (type), SpecReleaseChangesSchema (const), SpecReleaseSurface (type), SpecReleaseSurfaceSchema (const), composeReleaseChanges (function)), each present once in head's root.txt; root.json holds 220 rows.
  • No hand edit, my instrument: in a detached worktree at the head, real pnpm --filter @objectstack/spec build (exit 0) then gen:api-surface-declarations (exit 0, 「Wrote api-surface-declarations/ (17 entry points, 5343 declarations, 12.08 MiB)」) → git status --porcelain 0 lines, git diff --stat 0 lines. Regenerating all 17 shards from a fresh build is a byte-for-byte no-op on the committed tree — a hand-typed shard cannot survive that.
  • The queue's own gate output on merge-group run 35331317491 (job Type Check · consumer gates, 09:55:02Z, branch gh-readonly-queue/main/pr-18889-f347c793e16…) names exactly these 7 as + added, 「0 removed, 7 added, 0 reshaped」, remedy gen:api-surface-declarations — the regen diff is exactly that remedy's shape. 17 shards at head; ls-tree head vs f347c793e16 differs in ONE blob (root.txt 7ebf656f6da → 6c83181913b).

4. check:generated — TRUE, re-measured: 「Checking 16 generated artifacts」 … 「✓ All 16 generated artifacts are up to date.」 exit 0, at head after the real build. check-generated.ts at head registers the declarations family (lines 121–123); its GATED row count equals current main's.

5. check:api-surface-declarations — TRUE, re-measured: 「@objectstack/spec declaration text unchanged ✓ (17 entry points, 5343 declarations)」 exit 0.

6. Release gate at this head — TRUE, re-derived from real artifacts, ⛔ not transcribed. npm pack @objectstack/spec@17.3.0 from the real registry (exit 0, version read back 17.3.0, shasum 1ca896ffbc05…), unpacked; at the head worktree tsx scripts/build-spec-changes.ts --previous-package … exit 0 → release = {fromVersion 17.3.0, toVersion 17.4.0, added 386, removed 302, converted 0, migrated 0}; pnpm pack of the head tree (exit 0), unpacked; node scripts/check-release-spec-changes.mjs --previous … --published … → exit 0, 「✓ release 17.3.0 → 17.4.0 verified against both tarballs: 386 added, 302 removed, 0 converted, 0 migrated.」 Lit control: the published 17.3.0 as --published → exit 1 「ships no release section, but one is owed」. Dark control: --check on the restored committed copy → exit 0 「up to date」. spec-changes.json mutated (f17b29ec56e…) and restored under trap … EXIT INT TERM: hash before = after = 9dbc98682bfc131a8a18f41e971b27c86f99e8fa, git status --porcelain 0 lines. Also derivable: git diff --stat 1737a24b510 head -- packages/spec/api-surface/ packages/spec/export-origins/ = EMPTY (control over 89c6ec52b56..head = 2 files, +14), so the gate's inputs did not move.

7. Both merge sides survived — TRUE. 17/17 shards head vs main tip (control: 0 at 1737a24b510); api-surface-signatures.json absent at head and f347c793e16, PRESENT at 1737a24b510 (control); src/shared/refinement-projection.ts head vs main tip = 0-byte diff (control vs 1737a24b510: +159); 7/7 exports in root.json and 7/7 in root.txt; PR's lint.yml step and package.json script survive main's rewrites (item 1); main's 3 added packages/spec/package.json lines present (266–267). Delta path set 1737a24b510..head (81) equals main's window set exactly (comm both directions empty); the PR's changeset is 0-byte-different across the delta (control vs base: +48).

⭐ The dispute — the ROUND is right; the seat's dispatch was wrong for this window. git diff --stat 89c6ec52b56 f347c793e16 -- packages/spec/api-surface/ packages/spec/export-origins/ = EMPTY (exit 0, 0 lines). Hitting controls on the same instrument: over packages/spec/ in the same window = 37 files (main moved the 17 api-surface-declarations/*.txt, deleted api-surface-signatures.json, build-api-surface.ts 98±, registry.ts 47±, …); over the same two paths in the PR's own window 89c6ec52b56..1737a24b510 = 2 files, +14. Independent second instrument: the queue build's own check:api-surface on the merge onto f347c793e16 printed 「public API surface unchanged ✓」 at 09:54:59Z, three seconds before the declarations gate failed. What main moved here is the declaration-TEXT family and the retired signatures file, not the export listing the 386/302 delta is computed from — and my real re-run (item 6) confirms 386/302 unmoved. The seat's 「main has moved the export surface since」 was true of the PREVIOUS window (374 → 386) and was carried one window too far.

② Clause ② for the delta

The delta neither widens nor narrows; the standing Clause-②: yes (widening) is unchanged and remains the correct declaration. The regenerated shard records the .d.ts text of 7 exports the PR had already added and both prior records had already judged as 「扩大公开面」; it adds no export, key, closed-set member or registration. api-surface-declarations IS in packages/spec files[] (published bytes), so the delta is the published artifact catching up to a surface already declared — SKILL.md:515's pull-back case does not apply (nothing is pulled back), and its 「卡面复述仍是条款②」 clause is why this stays under the existing yes rather than converting to no. Mechanically: check-widening-tells --declaration no over the delta alone = exit 0 with root.txt reported NOT MEASURED (by the #16045 ruling the declaration-text family is a declared non-surface for the tell gate, its own lines 355–365), so that exit 0 is evidence about no surface at all; lit control over the whole PR vs main tip = exit 4, 14 tells, all the PR's own. Semver: minor on @objectstack/spec and @objectstack/cli, changeset byte-identical across the delta, Clause-②: yes (widening) line-anchored in changeset and PR body; check-clause2-carriers --pair 18889 exit 0.

③ 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, spec-changes.ts, spec-release-changes.ts, release.yml, changeset, api-surface/, export-origins/, committed spec-changes.json — 0-byte diffs 1737a24b510..head) or is main's own landed work arriving through the base. The one new artifact (root.txt) is the generated description of a surface both records already accepted, and regenerating it is a no-op.

Boundary flags — none blocks

  1. Path-set overlap on lint.yml / package.json (item 1): auto text-merge, proven equal to git merge-file and to driver-less merge-tree; both sides' hunks present. The prior record's diff-tree -c instrument is not sufficient alone on a merge with overlapping paths — merge-tree equality is.
  2. Governed surface: check-governed-merges.mjs --test over the 20 effective paths (GitHub get_files = local f347c793e16..head list, IDENTICAL) = exit 0 「NOT governed」; lit control with AGENTS.md = exit 3. Governed Surface Queue Guard success at head.
  3. CI at head, pinned to the sha (read 11:12Z): 47 check runs, 40 success + 7 skipped (Auto Label ×2, Check PR Size ×2, Packed-tarball smoke ×2, Console Pin Gate), 0 failure, 0 in progress; all seven required contexts success (Lint & Repo Gates 11:06:15Z, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). Validate Package Dependencies success.
  4. Main has moved again: f347c793e16..2767af8e835 = 1 commit, 3 files (plugin-hono-server fix + changeset), none under packages/spec, none merge=os-regen; driver-less merge-tree of head onto 2767af8e835 = exit 0 clean. The failure this round repaired cannot recur from that commit; it CAN recur from any later main commit registering another artifact family — the queue build is the only instrument for that.
  5. Regen commit trailer Co-Authored-By: Claude <noreply@anthropic.com> + Claude-Session: — model-free; harness form, exempt per AGENTS.md. Reporting only.
  6. PR body carries the seat's corrected 386 added, 302 removed and 「all 16」 lines; both now reproduced by my own run.

NOT MEASURED

  • The queue build itself and the release lane (no seat may trigger either); its local half (generate → pack → gate) was reproduced instead.
  • The os-regen-pending marker in the round's own $GIT_DIR (structurally impossible here, item 1).
  • Full local test suites / typecheck at head — CI's readings above, pinned to the sha; locally I ran the spec build, the two generators, check:api-surface-declarations, check:generated (16), the release gate with lit/dark controls, check-widening-tells, check-governed-merges --test, check-clause2-carriers.
  • The ruleset's required-context list — taken from record 5728233285, not re-read.
  • Scratch left for the seat to remove: worktree /home/user/objectstack-pr18889-cr (detached at head; git worktree remove --force), owned refs refs/pm-review/pr-18889-head and refs/pm-review/main-now in /home/user/objectstack/.git, and /tmp/claude-0/-home-user/d31c56ec-952d-5a16-945f-6e430b9a6d34/scratchpad/pr-18889/ (PROBE.git, prev/ = unpacked 17.3.0, packed/ = my pack, all logs with exit codes captured before any pipe).

Implemented-by: claude/issue-17080-per-release-spec-changes
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS — landing head 2733f3f2495146867eba94dff1d0fce6b7dc14df; the delta is exactly the gate's printed remedy and regenerates as a byte-for-byte no-op, both merge sides are byte-verified, check:generated 16/16 and the release gate 386/302/0/0 reproduce on real artifacts at this head, Clause ② is unchanged at yes (widening), and the seat's 「main moved the export surface」 premise is falsified for this window (the round was right).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

📌 复核记录升级 —— 同一裁断、更强的证据;并更正本席自己上一条记录里的一句话

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-18T11:19Z。升级后的记录原文见紧接本评论之前的那一条(5729240400)。

裁断没变:PASS,仍点名落地 head 2733f3f2495146867eba94dff1d0fce6b7dc14df。档位重取:claude-fable-5-1 105 / 105,off-tier 0。

⛔ 本席上一条记录(5729188327)里有一句现在不成立,当场更正

本席当时写:

那句「没手改分片」不是复核自测的……这一半取自 CI 在该 sha 上的 Type Check · consumer gates。

那句话现在是过期的。 复核的重活当时卡在验证锁队列里;锁拿到后它自己跑了,并把三条此前列在 NOT MEASURED 里的读数补成了亲测:

此前 现在
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

Merged via the queue into main with commit 8b48903 Sep 18, 2026
51 checks passed
@os-litant
os-litant deleted the claude/issue-17080-per-release-spec-changes branch September 18, 2026 11:45
os-steve pushed a commit that referenced this pull request Sep 20, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants