Skip to content

feat(spec)!: the ADR-0087 migration chain leaves the root entry for @objectstack/spec/migrations (#20646) - #20695

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20646-spec-registries-subpath
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20646-spec-registries-subpath

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20646

Clause-②: yes (narrowing — the ADR-0087 migration chain and change-manifest names, 17 values and 16 types, leave the package root @objectstack/spec for the new @objectstack/spec/migrations entry; the conversion layer stays on the root)

This is the source-side payback of the maintainer's ruling on objectui#11088 (decision 1, letter A: raise the console first-screen ceiling now, pay it back at the source). It builds the shape the domain:spec seat ruled on the card (comment 5891704646, option A), under the claim amendment that allows exactly one change to migrations/registry.ts: the generated D3 block for this narrowing's own ADR-0087 entry.

Why the root carried the text

sideEffects: false was already declared, so it was not the cause. Each entry ships as one flat module (tsup splitting: false). Inside it, a consumer's bundler has to keep every top-level call it cannot prove pure, and the migration registry computes things when its module loads: the list of majors, each step's rationale and step 18's conversion ids. So the whole registry (mostly the os migrate meta guidance text) rode in every bundle of the root, whatever the consumer imported. Rolldown (Vite 8's bundler, which the console uses), esbuild and rollup all agree.

The conversion layer cannot leave the root chunk: defineStack and normalizeStackInput call applyConversions, which reads ALL_CONVERSIONS at run time. Measured on the round-1 base 0cb72cf, dropping the conversions re-export as well moved the root by 1,828 bytes. It stays.

Byte table

Before is the merge base 1a75e39d4a, built in a separate worktree; after is this head. Both are tsup JS builds, gzip -9.

artifact before (raw / gzip) after (raw / gzip)
dist/index.js (CommonJS root) 3,780,033 / 1,067,061 2,009,810 / 565,386 (-46.8% raw)
dist/index.mjs 3,766,221 / 1,065,949 1,996,676 / 564,356
dist/browser/index.mjs (the ESM root a browser bundler pulls) 3,764,293 / 1,065,388 1,994,748 / 563,787
console-root probe: rolldown 1.0.3, platform browser, minified; the entry imports the 10 names objectui's non-test code imports from the root at the pinned .objectui-sha 2,261,622 / 700,884 1,024,463 / 301,287
light-consumer probe: rolldown, the entry imports only findClosestMatches 2,203,744 / 681,199 966,581 / 282,176
  • Source-map attribution of the root index.mjs: migrations/registry.ts went from 1,761,987 bytes to 0. conversions/registry.ts stays at 321,050.
  • The new entry: dist/migrations/index.js is 2,280,591 raw / 616,800 gzip. It carries the chain plus the conversion registry it reads.
  • Console acceptance: on the console-root probe the payback is 399,597 bytes gzip, against objectui#11088's 413.8 KB overage. objectui's own eager-closure budget reading is objectui's to take once its pin moves.

What changed

  • Root entry: packages/spec/src/index.ts stops re-exporting ./migrations/index.js. packages/spec/package.json gains the ./migrations export (import/require/types, no browser condition), and tsup.config.ts gains the entry. check:browser-reachable-entries measured the entry as linking no Node builtin and no server-only package, so no browser condition is needed. browser-reachable-entries.json lists it under unjudged.
  • ADR-0087 D3 entry: entries/semantic/18.migrations-entry-split.ts (form D, no tracker number), concatenated by gen:migration-registry. The registry diff against the merge base is that block only: 52 insertions, 0 deletions. conversions/registry.ts is byte-identical. gen:spec-changes and gen:upgrade-guide produce no diff, because major 18 is not yet in their window.
  • Importers moved to the subpath:
    • the one runtime importer, packages/cli/src/commands/migrate/meta.ts;
    • four tests: packages/cli/test/migrate-meta-default-range.test.ts, packages/cli/test/migrate-meta-engine-guidance.test.ts, packages/metadata-protocol/src/protocol.stored-migration.test.ts, and packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts (only applyMetaMigrations moves; ALL_CONVERSIONS stays on the root import).
    • This is the complete list: a TypeScript-AST census of every file naming one of the 53 exports of the two index.ts files, covering static, dynamic, namespace and type imports. Repo gates import the registry modules by relative path, so none moves. objectui at the pin imports none of the moved names; cloud was not measured.
  • Generated artifacts: api-surface/ and export-origins/ have a new migrations.json shard, and 33 names leave root.json in each.
  • Pin: packages/spec/src/root-entry-migrations-split.pin.test.ts checks four things, each negative with a positive control:
    • the root module exports none of the 17 moved values;
    • ./migrations exports all of them, and nothing it exports is on the root;
    • the root api-surface shard lists none of the 33 names, while the migrations shard lists all of them;
    • the root's static value-import graph does not reach migrations/ at all (the edge a bundler follows), and the exports map publishes ./migrations to the built files.
      No new gate, no byte ceiling.
  • Riders the gates forced (details in Acceptance notes):
    • content/docs/deployment/troubleshooting.mdx's ordered subpath sentence gains migrations (check:docs-spec-enumerations).
    • CATEGORY_TITLES.migrations becomes Migrations Entry, following the api-assembled precedent, so the migrations subpath is counted as a subpath and not as a 16th protocol namespace; the schema-closure pin and its docblock follow.
    • scripts/export-origins.test.ts's entry list gains ./migrations.
    • Stale comments are updated in the build-migration-registry.ts header and the tsup.config.ts entry-count note.
  • Changesets: @objectstack/spec minor with a BREAKING banner, the FROM to TO table and the registered migrations-entry-split disposition; @objectstack/cli patch.

Verification record

Heads named per reading. The final head is 8558334ab5, merged with origin/main 1a75e39d4a.

  • Reverse verification of the pin, committed first, mutated through scripts/ablation-replace.mjs, at c4fdf1105e:

    • Mutation: the root re-export was re-added ('mutation landed: anchor 1 to 0').
    • Result: the pin went red as predicted, 3 failed / 5 passed of 8. The failures were: the root exports none of the moved values; nothing ./migrations exports is on the root; the root graph does not reach the registry.
    • Restore: 'blob == HEAD (36e34ad74886) and git diff HEAD is empty'. The pin is green again, 8/8.
  • Tests:

    suite head result
    @objectstack/spec local project, full c4fdf1105e 575 files, 16926 tests passed
    @objectstack/spec repo project, 42 of 43 files c4fdf1105e 674 tests passed
    @objectstack/spec build-schemas-check-mode.test.ts, the four registry-copying blocks c4fdf1105e 24 passed
    pin, src/migrations, export-origins and schema-closure tests 8558334ab5 206 passed
    @objectstack/cli unit project, full c4fdf1105e 234 files, 3347 tests passed
    @objectstack/cli integration: the two touched migrate-meta files c4fdf1105e 10 passed, 1 skipped
    @objectstack/metadata-protocol stored-migration test c4fdf1105e 39 passed
    @objectstack/service-automation decision pin c4fdf1105e 22 passed
    @objectstack/runtime spec-subpath alias-coverage pin c4fdf1105e 6 passed

    NOT MEASURED locally: the other 63 tests of build-schemas-check-mode.test.ts (the file alone runs about 14 minutes, past the foreground cap); CI runs it.

  • Typecheck: @objectstack/spec passed at c1dcebf255. @objectstack/cli, @objectstack/metadata-protocol and @objectstack/service-automation passed at c4fdf1105e.

  • Gates:

    • Derived: node scripts/pm/dispatch-gates.mjs --commands derives 132 families on the final head. --ran reconciles them as 131 run and 1 NOT MEASURED, with exit codes recorded.
    • Heads: gates 1 to 72 of the first derivation ran at 5936236fb7, 73 to 110 at c1dcebf255, and on 8558334ab5 the ratchet and generated-artifact set was re-run together with the 22 docs families the docs rider added.
    • The four named gates, all exit 0: check:api-surface, check:published-files, check:dual-build-cjs-loads (105 require entry points across 66 packages load) and check:entry-nameability (494 call probes across 19 public entries).
    • Also exit 0: check:generated (all 15 artifacts current), check:adr-0087-registration ('registered migrations-entry-split (new here)') and check:browser-reachable-entries.
    • Two reds, both fixed and re-run green: check:docs-spec-enumerations (fixed by the docs rider) and check:dual-build-cjs-loads (PREREQUISITE NOT MET, cleared by a full build).
    • NOT MEASURED: check:pm-dispatch-gates. Its self-test alone outran the 590-second foreground cap twice on the shared box, and every case it printed passed. The diff does not touch dispatch-gates.mjs.
  • Lint: narrowed and proven, at 8558334ab5:

    • Scope: eslint --no-inline-config --format json over the 16 changed .ts/.mjs files, which is every changed file in eslint's population (**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus NEVER_LINTED).
    • Result: 16 files, 0 errors, 0 warnings.
    • Why the narrowing excludes nothing: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict. The full-tree pnpm lint is CI's.

Acceptance notes

  • The docs rider is a classification call the reviewer may want to see. Keeping migrations: 'Migrations Protocol' would have required listing Migrations as a 16th protocol namespace in the glossary, plugins/packages.mdx and four count claims. I followed the api-assembled precedent instead: a published entry that is not a metadata protocol domain is titled Entry. schema-closure.test.ts had pinned CATEGORY_TITLES.migrations to contain Protocol as corroboration that migrations is NOT declared schema-free. That assertion now pins toBe('Migrations Entry') and not.toContain('Vocabulary'). The substantive half, schemaClosureAbsenceIsDeclared('migrations') === false, is untouched.
  • Conversions stay on the root, and they still ride in every root bundle. ALL_CONVERSIONS is computed at import time (conversions/registry.ts), so the conversion registry (321,050 bytes of the root) stays in every root bundle, even for a consumer that never calls defineStack or normalizeStackInput. On the light-consumer probe that is about 40 KB gzip. That would need an edit to the conversion registry, which this card excluded. Carrier: objectui#11101's seat. Noted, not filed.
  • Upgrade guide: the D3 entry lands in major 18's semantic list. spec-changes.json and the upgrade guide do not project major 18 yet, so they are unchanged; they pick the entry up when the protocol major moves.

Generated by Claude Code

…objectstack/spec/migrations (wip: entry + exports)

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…ve the migration importers to @objectstack/spec/migrations

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…y as an entry, not a protocol namespace

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/flows.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/automation/hook-bodies.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/data-modeling/fields.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/data-modeling/objects.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/data-modeling/queries.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/deployment/cli.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/deployment/index.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/protocol/objectql/query-syntax.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/protocol/objectui/actions.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/ui/actions.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/ui/apps.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/ui/dashboards.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/upgrading.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))

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

  • content/docs/releases/v12.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-0.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-1.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-3.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-4.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-5.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))

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
  • 9 changed file(s) yielded no anchor (packages/spec/api-surface/migrations.json, packages/spec/api-surface/root.json, packages/spec/browser-reachable-entries.json, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 143 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 d2820876f7452e44620db6a1810a847613a46f67 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 24c1006eaa73c1df2ed353cf2a0a9c8dfd208bca — the merge of head 6ce9c2b349ef86efb0cebdb50eb9bd04ce54be94 into base d2820876f7452e44620db6a1810a847613a46f67, 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 24c1006eaa73c1df2ed353cf2a0a9c8dfd208bca && git checkout 24c1006eaa73c1df2ed353cf2a0a9c8dfd208bca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d2820876f7452e44620db6a1810a847613a46f67 6ce9c2b349ef86efb0cebdb50eb9bd04ce54be94 && git checkout -B drift-repro d2820876f7452e44620db6a1810a847613a46f67 && git merge --no-ff 6ce9c2b349ef86efb0cebdb50eb9bd04ce54be94

node scripts/docs-audit/affected-docs.mjs --json d2820876f7452e44620db6a1810a847613a46f67

⚠️ 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 d2820876f7452e44620db6a1810a847613a46f67 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8558334ab5b9a16fe73f898cc7dd9f3985799e51
Local-runs: none

Inputs: card #20646 (body, claim 5890821871, take-order note, round-1 report 5891665337, ruling and claim amendment 5891704646, round-2 report 5895779965), the maintainer's ruling on objectui#11088 D1 (5890854293), PR #20695 (body, one bot comment, 25-file list, the net diff against merge base 1a75e39d4a), and the head's check-runs read once. Every code fact below was read from git objects at the head (git show, git grep, a git archive of packages/spec/src into scratch for a static import walk); nothing was built, run or re-run.

① Derived judgments

  • The narrowing, enumerated. api-surface/root.json loses exactly 33 rows and export-origins/root.json the same 33; at the head no shard but migrations.json carries a src/migrations/ origin, and migrations.json carries all 33 and nothing else: 12 consts (MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR, the seven Spec*Schema), 1 class (MigrationFloorError), 4 functions (applyMetaMigrations, composeMigrationChain, composeReleaseChanges, composeSpecChanges) = 17 values; 9 interfaces and 7 type aliases = 16 types. src/migrations/index.ts exports that set and no more. Matches the dev's 17 and 16 and the ruling's A. Right.
  • The root graph does not reach the registry. Independent of the pin: a static walk of the head's src with the pin's own edge rules (value-bearing import/export … from, type-only statements dropped) puts 134 modules in the root graph, none under migrations/, conversions/registry.ts included; no non-test module outside migrations/ imports a migrations/ path (two tests do, by relative path), and conversions/* imports nothing from migrations/ (the dependency runs the other way: migrations/chain.ts and migrations/registry.ts read the conversions registry). defineStack and normalizeStackInput reach applyConversions and ALL_CONVERSIONS, so the conversion layer is a live root dependency and stays, as ruled. The pin proves the same fact by walking the static value-import graph from index.ts with an anti-vacuity floor (more than 100 modules, conversions/registry.ts present) and a positive control from migrations/index.ts; the dev's mutation (re-adding the re-export) turned 3 of its 8 cases red. Right.
  • The subpath, published. package.json gains ./migrations with import/require, each with types and default, the same shape as ./meta-spelling and ./shared; files already whitelists dist; tsup.config.ts gains the 19th entry (root plus 18 subpaths) and its entry-count note. check:published-files (SUFFICIENT, and ANNOUNCED only reds a narrowed exports map, which an added subpath is not), check:dual-build-cjs-loads, check:entry-nameability, check:browser-reachable-entries, eager-entry-import.test.ts and the runtime spec-subpath-alias-coverage pin all derive their population from the exports map or dist, so no hand list is owed there; the one hand list in the tree, export-origins.test.ts ENTRY_NAMESPACES, is extended. declaration-map/ shards by schema category (it has no api-assembled.json or meta-spelling.json either), so no shard is owed. The three packages whose tests moved resolve @objectstack/spec/* by node resolution against the built dist, which CI builds before Test Core. Right.
  • No browser condition, listed unjudged. The classification is right: every exports subpath must sit in exactly one ledger section, and browserReachable is a schema-free promise the entry cannot make (it links zod through spec-changes.ts). The browser-condition half rests on the BUILT bundle, and I name the reason: the entry's source graph reaches data/driver/pg-url-grammar.server.ts (a static pg-connection-string import) along migrations/registry.ts → conversions/registry.ts (CONVERSIONS_BY_MAJOR) → data/driver/config-registry.zod.ts (resolveDriverId) → data/driver/postgres.zod.ts → the grammar arm. Whether dist/migrations/index.mjs drops that arm (the pureSchemaConstruction annotation lets esbuild discard PostgresConfigSchema when only resolveDriverId is used, and this is the first non-conditioned entry to carry the conversions graph) is exactly what check:browser-reachable-entries measures on EVERY module entry, judged or not. The dev ran it green after a full build; in CI it runs under Type Check · consumer gates, unconcluded at my read. Judged right, conditional on that check-run; if it reds, the remedy (the entry joins browserConditionedEntries and gains the browser condition) sits inside the amended surface and needs no re-ruling.
  • The registry, insertion-only. Against the merge base packages/spec/src/migrations/registry.ts is 52 insertions, 0 deletions: the generated D3 block for migrations-entry-split, id-sorted within major 18 between metadata-plugin-additional-types-retired and object-block-sort-item-array (order is derived by build-migration-registry.ts, never declared). conversions/registry.ts at the head is the merge base's blob (1106a3b8f091), untouched. Both halves of the amendment hold. The entry itself is form D (no tracker number in any author-shown string), names all 33 in surface, and states the replacement, the reason and the acceptance criteria; check:spec-changes and check:upgrade-guide are green on this head (see gate coverage), confirming major 18 is outside their projection window and no artifact change is owed. Right. Landing note, not a PR fact: origin/main advanced to f379f57f44 (refactor(spec): major 18's conversions as identifier-sorted entries with an explicit application order, so two retirements merge clean (#20574) #20685) during this review, rewriting conversions/registry.ts (117 added, 52 removed) and adding one D3 entry (62 lines) to the migrations registry; the only overlap with this PR's file list is that registry, so the seat's regen-merge before enqueue keeps the block insertion-only.
  • In-repo consumers. A re-grep at the head of every file that imports the spec root in any shape (from, bare import, require(, dynamic import(, either quote style; 365 files across packages, scripts, examples, skills and docs) for any of the 33 names hits only the two moved sources (now on the subpath), the changeset's own before-and-after example, and CHANGELOG history (release-owned). No docs code block, skill, example or script imports a moved name from the root; repo gates read the registry by relative path; inside packages/spec/src no test reaches a moved name through the root barrel except the new pin, which asserts their absence. The dev's five-importer census holds. Right.
  • objectui at the pin. .objectui-sha is unchanged (dd3f7e1be3). A grep of the whole objectui tree at that sha for the 33 names hits only docblocks and changeset prose (RETIRED_DEFS_BY_MAJOR, SemanticMigration as citations); no import of any moved name. The Console Pin Gate check-run was skipped by its path filter (the pin did not move), so this grep is the evidence: the removal cannot red the pinned sibling (AGENTS.md Post-Task Checklist 4). Right.
  • The gate-forced riders. content/docs/deployment/troubleshooting.mdx: check-docs-spec-enumerations.mjs compares the Available subpaths sentence as an ORDERED list against the exports map's bare ./name keys; migrations lands last, matching the map. Forced. Right. CATEGORY_TITLES.migrations to Migrations Entry: the same gate derives NAMESPACES as the subpaths whose title ends in Protocol; with ./migrations now a subpath, the old Migrations Protocol would make it a 16th namespace and red the glossary, plugins/packages.mdx and every count claim, so the alternative to the rename is declaring a migration-tooling entry a protocol namespace, which it is not. Precedent 'api-assembled': 'API Assembled-Stage Entry'. The title feeds no generated page (there is no json-schema/migrations/) and no other test asserts it. Forced. Right. schema-closure.test.ts: toContain('Protocol') became toBe('Migrations Entry') plus not.toContain('Vocabulary'); the assertion's job is to corroborate that the two undeclared categories do not claim the schema-free word, and that negative half is kept while the positive half becomes exact; schemaClosureAbsenceIsDeclared('migrations') === false and CATEGORIES_WITHOUT_SCHEMA_CLOSURE equal to ['meta-spelling'] are untouched. Not a weakening. Right. Stale comments in build-migration-registry.ts and tsup.config.ts: accurate now. Right.
  • The pin test. Four halves, each negative with a positive control; runs in the spec vitest project (Test Core); reads only inside its own package, so no cross-package input declaration is owed. No new gate and no byte ceiling, as the claim forbade. Right.
  • The moved importers. packages/cli/src/commands/migrate/meta.ts keeps ObjectStackDefinitionSchema and normalizeStackInput on the root and takes the five chain names from the subpath; the four tests move only the chain names (ALL_CONVERSIONS stays on the root in the service-automation pin). Same objects, same chain. Right.
  • The byte claims. Not re-measurable here. The diff's shape can produce them: the registry's share of the root ESM bundle leaves because no root module value-imports migrations/ (the walk above), and the conversions registry's share stays because the root reaches it; the console-root probe's drop follows from the same graph. Plausible, not verified.

② Semver level

  • @objectstack/spec minor: the body opens feat(spec)!:, carries the BREAKING banner, a FROM → TO table that covers all 33 names (3 + 2 + 3 + 2 + 7 + 16), the one-line fix (change the import path), and exactly one ADR-0087 disposition marker, registered migrations-entry-split, naming an id this diff adds. A narrowing is BREAKING; under the launch-window convention it grades minor with the banner and the ADR-0087 disposition as the carriers (check-changeset-no-major.mjs refuses major; check-adr-0087-registration.mjs reads the arm from the changeset body, where the declaration reads yes (narrowing) in the exact spelling). Right, and the ruling's level.
  • @objectstack/cli patch: the only published consumer whose import moved; it replays the same chain. Right. metadata-protocol and service-automation change test files only, so nothing published moves and no changeset is owed. No skip-changeset. Right.
  • The declaration line. The PR body carries yes (narrowing — the ADR-0087 migration chain and change-manifest names, 17 values and 16 types, leave the package root …). Judged from scripts/pm/clause2-line.mjs, the fleet's one reader: the value token must be the first thing after the colon (yes); the arm is a parenthetical opened as the next non-blank thing whose FIRST word is widening or narrowing; text after the arm word is the seat's argument and is not read (the file's own example is no (narrowing — the IANA zone domain)). It reads declared, value yes, arm narrowing; the bare yes (narrowing) spelling is not required, and the changeset uses it anyway. check-changeset-no-major.mjs reads this line off the PR body for its level axis, and the Check Changeset run concluded success; check-governed-queue-guard.mjs reads no declaration line. Consistent with the ruling's Clause-②: yes (narrowing).

③ Boundary flags

  • Dev flag, the docs-rider classification call: answered above, forced and right; no pin weakened.
  • Dev flag, the conversion registry still rides in every root bundle (about 40 KB gzip on a light consumer), noted, not filed, carrier named as objectui#11101's seat: outside the amended surface by the amendment's own words (conversions/registry.ts does not change at all); not a defect or a contract violation (sideEffects: false is honoured at module grain), so Prime Directive 10 puts it in the acceptance notes, where it is. One correction to the note: an objectui card cannot carry an edit to this repository's conversions registry, so that pointer is a pointer, not a carrier; if the payback is wanted it is a new objectstack card. Not escalated.
  • Dev flag, spec-changes.json and the upgrade guide unchanged because major 18 is outside their window: answered by green check:spec-changes and check:upgrade-guide on this head.
  • Dev flag, the 1,828-byte reading quoted from the round-1 base and softened to "under two kilobytes" in the entry: not load-bearing (A was ruled on the live-dependency argument), and the PR body says where it was measured. Answered.
  • Dev NOT MEASURED, check:pm-dispatch-gates: the diff does not touch dispatch-gates.mjs; CI runs it under Lint & Repo Gates. Answered, gate-covered.
  • Dev NOT MEASURED, the other 63 tests of build-schemas-check-mode.test.ts: CI runs them under Test Core. Answered, gate-covered.
  • cloud not measured: the ruling accepted the BREAKING banner and the D3 entry as the downstream carrier. Answered by the ruling.
  • Round-2 open_questions: none.
  • Reviewer's flag: the built ./migrations bundle's server-only link, above, is answered by Type Check · consumer gates, unconcluded at my read; the seat re-reads it before enqueue.
  • Gate coverage at the single read of the head's 33 check-runs: 15 concluded success (Auto Label, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, filter, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, Type Check · source gates, Validate Package Dependencies); 2 skipped (Console Pin Gate by path filter, judged above; Packed-tarball smoke, opt-in); 0 red; 16 not concluded: Build Core (build, check:dual-build-cjs-loads), Lint & Repo Gates (check:generated, check:migration-registry, check:docs-spec-enumerations, check:published-files, check:cross-package-test-inputs, check:test-source-alias, lint, check:pm-dispatch-gates), Type Check · consumer gates (check:api-surface, check:browser-reachable-entries, check:entry-nameability, check:dual-source-exports, check:published-readme-exports), Type Check · workspace and Type Check · debt ledger (the consumers' typecheck), Test Core 1 to 6 (the pin, the moved tests, build-schemas-check-mode), Dogfood Regression Gate 1 to 3, Dogfood Verify CLI, Temporal Conformance. The green source-gates run already answers check:export-origins, check:spec-changes, check:upgrade-guide, check:llms-txt, check:docs, check:future-spec-major, check:generated --reconcile-only and the spec typecheck. Nothing red is this diff's; the Tier S landing waits for every check, which is the seat's read, not this record's.

Implemented-by: claude/issue-20646-spec-registries-subpath
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 18:31
@objectstack-fleet
objectstack-fleet Bot marked this pull request as draft September 29, 2026 18:32
…ec-registries-subpath

# Conflicts:
#	packages/cli/src/commands/migrate/meta.ts
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Validate Package Dependencies is red on 360efc96e4, and not because of this PR · domain:spec seat 2 (session_014EJ1ED8X4MMrT18BhVx4tx) · 2026-09-29T19:06Z


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20646-spec-registries-subpath branch September 30, 2026 00:04
This was referenced Sep 30, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…undle in view (objectstack-ai#20646) (objectstack-ai#20743)

Fixes objectstack-ai#20646

Clause-②: no

`Console Pin Gate` is red on `main` since `fbec216e2d` (PR objectstack-ai#20695, the
`@objectstack/spec/migrations` entry split). Its build step exits 2:
"Neither spec appears in the built console — no @objectstack/spec
content matched." The injection works. The check was reading text the
console never bundles. This PR makes the build-time probe derivation
choose with the bundle in view. The fresh leg stops reading entries the
console never imports, and the stale leg becomes strictly stronger.

## Root cause, measured

`scripts/console-spec-probes.mjs` builds each spec's blob from every JS
file the package's `exports` map resolves to. The assertion then took
the alphabetically first unique `.describe()` text on each side. A
console bundles only the entries it imports, so either probe could come
from an entry the bundle can never carry.

The readings below compare the published `@objectstack/spec` 17.4.0 that
objectui locks at pin `dd3f7e1be356` with the framework spec.

| leg | framework | fresh witness (carried by) | in bundle | stale
detector (carried by) | in bundle | old check |
| --- | --- | --- | --- | --- | --- | --- |
| dark | `f927864ea0` (main before the split) | "A public export added
or removed by one release." (`.`) | yes | "Accepted developer agreement
version" (`./cloud`) | no | exit 0 |
| lit | `fbec216e2d` (`origin/main`) | the same text, now
(`./migrations`) | no | the same (`./cloud`) | no | exit 2 |

- The witness moved with the split from the root to `./migrations`,
which the console never imports. Meanwhile 102 of the 142 injected-only
descriptions ARE in that bundle; the first sorts from `./ui`.
- The stale leg was already blind. Its detector sits in the published
`./cloud` entry, which objectui's shipped source never imports: 116
`/ui`, 62 `/data`, 17 `/kernel` and so on, and no `/cloud`. A console
built WITHOUT the injection carries 56 of the 160 published-only
descriptions, and not that one.
- The spec side is not the cause. The console never imported the change
manifest, and the injected spec is in the bundle. `packages/spec` is
untouched here.

## The fix (`scripts/console-spec-probes.mjs`, `chooseProbes`)

- **Fresh witness:** the first injected-only description the bundle DOES
carry. With none carried, "neither spec appears" is still exit 2.
- **Stale leg:** judged over EVERY published-only description. It fails
when ANY of them is in the bundle, and reports the first one found with
the count. A bundle the old single pick flagged is still flagged, so
this is strictly stronger.
- **Stamp:** keeps its shape and version. With nothing published in the
bundle, the stamped detector is the same one `pickProbe` chose, so
`check:console-injection`'s cache-hit replay is unchanged.
- **Assertion script:** `scripts/assert-console-spec-injection.mjs`
calls `chooseProbes` and prints the counts. The exit ladder is
unchanged: 0 verified, 1 published spec bundled, 2 unverified.
- **Self-test:** `scripts/check-console-injection.mjs` gains battery 13
(roster floor 10 to 11). It runs the real assertion script on fixtures
whose first unique candidate is not in the bundle:
  - a working injection verifies, and its stamp replays green;
- a published-only description the old pick skipped fails the build and
writes no stamp;
  - neither side present stays exit 2.

I checked that the battery can fail: reverting `chooseProbes` to
first-pick semantics made it red with 5 failures, and restoring the fix
turned it green again.

## Proof

All legs ran against real `vite build`s of the console, with the old and
the new assertion on the same bundle.

| console build | objectui pin | new check | old check |
| --- | --- | --- | --- |
| injected spec from `fbec216e2d` | `dd3f7e1be356` | exit 0 (witness
from `./ui`; 102/142 present; 0/160 published) | exit 2 |
| NOT injected (published spec) | `dd3f7e1be356` | exit 1 (56/160
published-only descriptions present) | exit 2 |
| a bundle with no spec text | — | exit 2 ("Neither spec appears") | — |
| injected spec from `f927864ea0` (dark) | `dd3f7e1be356` | exit 0
(111/142 present) | exit 0 |
| injected spec from `fbec216e2d` | `db11afd49670` (PR objectstack-ai#20706's pin) |
exit 0 (102/142; 0/160) | exit 2 |
| NOT injected (published spec) | `db11afd49670` | exit 1 (56/160) |
exit 2 |

On this branch's head, the Console Pin Gate's steps run locally all
pass: `bash scripts/build-console.sh` with the injected spec exits 0;
the dist presence assert passes; `pnpm check:console-sha` exits 0; and
`pnpm check:console-injection --require-stamp` exits 0 and replays the
new stamp.

## Verification record

- `dispatch-gates --commands` for the 3 changed paths derives 31
families. `--ran` reconciles them as 30 run, all exit 0, and 1 NOT
MEASURED: `check:pm-dispatch-gates`, whose self-test alone outruns the
590-second foreground cap here. It does not read these files' behaviour.
- `node scripts/check-console-injection.mjs --self-test` passes: 44
assertions, battery floor met.
- Lint, narrowed and proven: `eslint --no-inline-config` on the 3
changed files reports 0 errors and 0 warnings. The config enables no
type-aware linting, so untouched files' verdicts cannot move.
- No changeset: the diff touches only root `scripts/`, which ship in no
published package (`@objectstack/spec-monorepo` is private).

## Acceptance notes

- **The cache-hit replay's stale leg is still a single stamped
detector.** At this pin that detector is `./cloud` text no console
bundles, so on a dist-cache HIT the replay cannot catch a published
spec. The build-time leg now can. Fixing the replay needs a
multi-detector stamp (a `stampVersion` bump that invalidates every
cached dist under the unchanged cache key), which is outside this claim.
Carrier: the `domain:spec` seat. Noted, not filed.
- **CI on this head:** `Console Pin Gate` (job 109696257740) concluded
success. The dist cache missed, so step 11 built the console and ran the
changed assertion (105 of 145 injected-only descriptions present, all
160 published-only absent), and steps 12 to 14 passed on that fresh
dist.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… — no queued PR left the queue on a draft conversion (objectstack-ai#20845)

Fixes objectstack-ai#20764

Clause-②: no

## What changes

`.claude/skills/pm-dispatch/references/platform-readings.md`, lines
41-43 only. The rewrite is net 0 lines, and the three new lines are 120
/ 118 / 120 bytes (cap 120).

| line | before | after |
|:--|:--|:--|
| `:41` | 转 draft 不是可靠的踢队手段:两向相反读数并存,处置按最坏走。 | 转 draft
不是可靠的踢队手段:本仓与姊妹仓均见已入队转 draft 仍保位照合,处置按最坏走。 |
| `:42` | 本仓转 draft 同时掉 auto-merge 与队列成员资格,不自动恢复,转正后重挂;姊妹仓曾保位照合。 | 本仓转
draft 同秒掉 auto-merge 仅见于未入队时,转正后重挂;已入队者 2026-08-08、09-29 照合。 |
| `:43` | 补救:转 draft 与卸载 auto-merge 都做 —— 本仓卸载 auto-merge 单独不踢队。 | 补救:转
draft 与卸载 auto-merge 都做,本仓卸载单独不踢队;两手齐做亦未见踢队(08-08、09-28)。 |

The remedy on `:43` is unchanged: do both acts. No rule is added. The
in-file citations are dates, because `pnpm check:pm-skill-id-lint` goes
red on a `#`-number anywhere under `.claude/skills/pm-dispatch/`, and
this file cites no PR number today. The PR numbers and timeline events
are in this body. That is where the id-lint header puts a reading's
provenance.

## The readings (re-readable: `GET
/repos/objectstack-ai/objectstack/issues/N/timeline`)

I ran a read-only timeline scan over objectstack-ai#4700-objectstack-ai#4900, objectstack-ai#6650-objectstack-ai#6850 and
objectstack-ai#20400-objectstack-ai#20840. It looked for two patterns: a `convert_to_draft` between
`added_to_merge_queue` and the next `removed_from_merge_queue`, and a
`convert_to_draft` on a PR that was armed but not queued. The scan found
every row below. The table is complete for those windows only, not for
the whole repo.

**Queued, then converted to draft.** None of the four left the queue
because of the conversion.

| PR | queued | draft | disable sent? | what followed |
|:--|:--|:--|:--|:--|
| objectstack-ai#6732 | 2026-08-08T14:01:50Z | 14:04:32Z | claimed in objectstack-ai#6799's text; no
`auto_merge_disabled` event | the queue merged it at 14:38:56Z, 34 min
later, still draft |
| objectstack-ai#20420 | 2026-09-28T13:43:01Z | 14:02:28Z | claimed in the seat's
14:04Z comment; no event | its own group `pr-20420-3cf6449` had `Lint &
Type Check` = failure at 14:01:49Z, before the draft;
`github-merge-queue[bot]` removed it at 14:11:24Z, unmerged |
| objectstack-ai#20442 | 2026-09-28T13:45:42Z | 14:02:50Z | claimed in the seat's
14:04Z comment; no event | the queue built a new group
`pr-20442-b285508` for it at 14:11:29Z, 9 min after the draft; that
group's `CI` = failure at 14:30:14Z; removed at 14:35:44Z, unmerged |
| objectstack-ai#20695 | 2026-09-29T23:32:51Z | 23:57:02Z | no (the card discloses:
draft alone) | the queue merged it at 00:04:21Z, 7 min later, still
draft |

**Armed, not queued, then converted to draft.** Each one dropped
auto-merge.

| PR | armed | draft | `auto_merge_disabled` | after |
|:--|:--|:--|:--|:--|
| objectstack-ai#4745 | 2026-08-03T01:28:46Z | 01:29:30Z | 01:29:30Z (same second) |
ready, re-armed 01:30:52Z, queued, merged |
| objectstack-ai#6727 | 2026-08-08T13:42:58Z | 13:44:36Z | 13:44:36Z (same second) |
ready, re-armed 13:46:02Z |
| objectstack-ai#6829 | 2026-08-08T23:37:05Z | 23:40:25Z | 23:40:26Z (1 s) | ready and
enqueued 6 h later |

Each disable event carries the converter as its actor. A timeline cannot
tell whether the platform wrote it or a scripted follow-up did. The line
records what was observed ("同秒").

## Where the old reading came from

- The "drops queue membership" reading first appears in `db6581a5`
(objectstack-ai#4893, card objectstack-ai#4892, 2026-08-03). It cites no PR. That is the same day
objectstack-ai#4745 dropped auto-merge on an armed PR that was not queued. The
queue-membership half has no instance behind it.
- `cd704cc4` (objectstack-ai#6799) later cited objectstack-ai#6732 for "only draft evicts; disable
alone does not". In objectstack-ai#6732's own timeline the PR stays queued after the
draft and is merged by the queue 34 minutes later.
- The sister-repo half ("姊妹仓曾保位照合", objectui 2026-08-25) is carried over
as it was. I did not re-read it.

## How this departs from the triage direction (5903760674)

I did not choose these quietly. Each one is a place where a measurement
changed what the direction assumed:

1. **`:42` is split by queue state, not "mixed" in the sense of opposite
readings.** No queued PR in the scanned windows left the queue because
of a draft conversion. The direction assumed the old half had its own
measurement. It has none, and the one PR later cited for it reads the
other way. What does go both ways in this repo is the effect by state:
unqueued, auto-merge drops; queued, the PR keeps its place.
2. **`:43` says "not seen to dequeue", not "not measured".** Three PRs
(objectstack-ai#6732, objectstack-ai#20420, objectstack-ai#20442) have both acts claimed in writing, and none was
removed by them. The caveat: on a queued PR, the disable leaves no
timeline event, so whether it was sent rests on the seats' own comments.
3. **`:41` loses "两向相反读数并存".** No opposite reading survived the re-read.

## Acceptance notes

- **Out of scope, same family, Tier H.** `AGENTS.md` states the
falsified reading as a premise in three places. Prime Directive objectstack-ai#14 says
"draft is what removes queue membership, disabling alone drops only the
arming". Multi-agent §7 says "flipping back to draft drops auto-merge
and queue membership at once". The "State on your PR" paragraph says the
draft flag "flipped back destroys auto-merge and queue membership at
once". I did not touch them. They are reported to the seat.
- **Eviction.** The only dequeue acts measured in this repo are these
two. First, the queue's own ejection after a red group build (objectstack-ai#20420,
objectstack-ai#20442). Second, a manual removal by a maintainer with no draft
conversion (objectstack-ai#20797, `removed_from_merge_queue` 2026-09-30T07:33:36Z).
Whether the remedy should name a different act is a question for the
seat. It is not a rule this PR adds.
- **Changeset.** `.claude/**` is not in any package's `files[]`, so this
PR publishes nothing. `skip-changeset` is the seat's to apply.
- **Governed surface, Tier S (`.claude/**`).** This PR stays a draft
until a `## Contract review` record at `CONTRACT_REVIEW_TIER` exists for
its head.

## Verification (head `02ea176a1`)

- The 20 gates from `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` all exit 0. They are
`check-closing-keyword-parity` (plus `--self-test`),
`check-comment-mask-corpus`, `check-governed-queue-guard --self-test`,
`check-harness-current --self-test`, `lint
check:doc-formula-expressions` (after building `@objectstack/lint` and
its dependency closure under `os-verify-lock.sh`),
`check:agent-test-spelling`, `check:cross-package-test-inputs`,
`check:doc-authoring`, `check:driver-memory-census`,
`check:gitlink-declared`, `check:nul-bytes`, `check:pm-governed-merges`,
`check:pm-half-states`, `check:pm-skill-id-lint`,
`check:pm-skill-ratchet`, `check:refd-timer-probe`,
`check:required-contexts`, `check:skill-frame-sync` and
`check:watch-hint-literal`.
- `pnpm check:pm-settings-deny-roster` was also run, because its roster
lives under `.claude`. Exit 0.
- `--ran` reconciliation: 20 derived, 20 run, 0 NOT-MEASURED. All 20
recorded an exit code.
- `check:pm-skill-ratchet` printed: `platform-readings.md is 469 lines
(ceiling 469; headroom 0)`, widest table row 0 bytes (pin 0).
- Not measured locally: `check-required-contexts --verify-required-set`
and `check-half-states --provenance`, which read the workflow event, and
the four CI type-check lanes. This diff touches no TypeScript.

## 维护者速读(草稿)

- **改了什么**:PM 协议参考文件 `platform-readings.md` 第 41-43 行,关于「已入队 PR 转 draft
能否踢出合并队列」的三条平台读数。行数不变。
- **为什么改**:旧文说本仓转 draft 会掉出队列。回查时间线后发现,本仓 4 个已入队后转 draft 的 PR 都没有因此出队。其中
2 个带着 draft 状态被队列直接合入,另外 2 个是在自身队列构建变红之后才被移出。旧说法唯一引用的实例,时间线恰好反向。转 draft
会掉 auto-merge,这一点只在「未入队」时成立。
- **风险与代价(含回滚)**:只改文档读数,不改任何规则或代码。补救仍是两手都做。风险在于读者以为有可靠的踢队手段,而实际没有。回滚就是
revert 这一个提交。
- **席位意见**:
- **你要做的**:无需操作,由席位按 Tier S 复核后落地。若要让 `AGENTS.md` 里的同一旧说法一并修正(Tier
H),需要你点头另开 PR。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…le Pin Gate and misses its dist cache (objectstack-ai#20945)

Fixes objectstack-ai#20765

Clause-②: no

A head that moves the spec's entry layout now runs `Console Pin Gate` on
the PR, and misses the dist cache so the gate judges it. Before this,
such a head skipped the job on every push, and the first run was the
merge-queue build, where a check outside the required set cannot stop a
merge (objectstack-ai#20695: `main` red on this check for about 2 h 17 min).

## What changes

- **`ci.yml` `filter` job, `console:` list** gains
`packages/spec/package.json` and `packages/spec/tsup.config.ts`. The
comment above the list now says which entries are build inputs and which
are guards, and why the rest of `packages/spec` stays out.
- **`ci.yml` `console-pin` job, dist-cache key** (all three spellings:
restore, the `CONSOLE_DIST_CACHE_KEY` env, save) now hashes every build
input the list names: the pin, `scripts/build-console.sh`, the two probe
scripts (`scripts/assert-console-spec-injection.mjs`,
`scripts/console-spec-probes.mjs`), and the two spec files. Comments
that described the old key are corrected. The restore step name now says
what it is keyed on.
- **`scripts/check-ci-filter-parity.mjs`** gains a second subject,
`judgeConsole`, run by `main()` in both lint.yml legs. It holds five
things of the checked-in ci.yml: every `console` entry is a literal
path; the key is spelled one way everywhere the job uses it; every
hashed path is a `console` entry; every `console` entry is hashed or a
declared guard (`CONSOLE_GUARDS`: ci.yml and the two check scripts,
which run on a hit and a miss alike); and every declared guard is still
listed and not hashed. There is a new self-test battery (8) with 20
cases, and the battery floor goes from 7 to 8.
- **`scripts/pm/check-expected-skips.mjs`**: the `Console Pin Gate`
row's reason text names the two spec files. Only the prose changes. The
row's gate, the job's `if:` and the roster shape are unchanged.
- **`scripts/check-console-injection.mjs`** (comments and the `remedy()`
fallback text only; seat ruling 5920200169, Q2): the header no longer
restates the dist key. It names the two restore steps that each spell
one, and says the keys now differ. Its section is renamed "Why the rest
of packages/spec is NOT in ci.yml's console filter" and says the spec's
entry layout is in both the filter and ci.yml's key. The `remedy()`
fallback, used only when `CONSOLE_DIST_CACHE_KEY` is unset, prints `gh
cache delete "KEY"` plus a line naming where each workflow spells its
key, instead of a stale copy of the old key.
- **`.github/workflows/release.yml`** (comment only; no key, step or env
change; seat ruling 5920200169, Q1 = B): the comment above the dist
restore step says its key hashes the pin and the build script, ci.yml's
hashes a superset, the two workflows no longer share dist entries, and
the narrower key is deliberate.

## Why the key had to move too (H1, measured)

On a cache hit the job skips `Build the Console SPA`, and
`assert-console-spec-injection.mjs` runs inside that step. What runs on
a hit is `check:console-injection`, which replays the stamp the earlier
build wrote. The only thing it reads from the tree is the probe-expiry
check over the union of all exports entries, and moving text between
entries does not change that union.

- **The queue build that went red was a cache MISS.** Run 36645820047,
job 109668493655: `Restore vendored Console dist` took 0 s, `Build the
Console SPA…` ran 23:37:45 to 23:41:37 and failed, and the only failure
annotation is "Process completed with exit code 2" (the assert script's
inconclusive exit, "Neither spec appears in the built console"). The job
log itself could not be read from this container because the log blob
host is refused by the egress policy.
- **Fixture replay of the objectstack-ai#20695 shape** (scratch fixture: a root entry
whose first injected-only description moves to a new `./migrations`
entry the console does not import; the assert and probe scripts as they
stood at `9c8f113c~1`):
- leg 0: the pre-move build stamps the dist. Exit 0; witness "A public
export added or removed by one release."
- leg 1, **cache hit** on the post-move head: `node
scripts/check-console-injection.mjs --dist dist-pre --spec tree-post
--require-stamp` prints "✓ @objectstack/spec: the dist carries this
tree's copy, and not the published one." with **exit 0**. The hit is
green without judging the change.
- leg 2, **cache miss** on the same head: the rebuild prints "✗ Neither
spec appears in the built console" with **exit 2**. This is the queue's
red.
- **How often the cache is cold anyway.** On `merge_group`, 32 of the 38
finished `Console Pin Gate` runs from 2026-09-30T13:46Z to 20:23Z
rebuilt, and 6 hit. On the hourly `schedule`, 25 of 30 runs from
2026-09-24 to 09-27 rebuilt. So widening the filter alone would have
judged this shape most of the time, but not every time. Moving the key
makes the rebuild certain for such a head.

## Which spec paths (H2, measured)

What the console build and the probe derivation read from
`packages/spec`:
- `package.json` `exports`: objectui's `OBJECTSTACK_SPEC_DIST` hook
derives one alias per exports entry and refuses a missing target
(`apps/console/vite.config.ts`,
`scripts/vite-objectstack-spec-dist.ts`), and `readSpecBlob` in
`scripts/console-spec-probes.mjs` resolves every entry.
- `tsup.config.ts`: its `entries` list decides which built file each
exports entry points at, and `build-console.sh` builds the spec through
it.
- `dist/index.mjs` and `json-schema/openapi.json` are build sentinels.
They are generated, not authored.

Over the first-parent commits on `main` from 2026-08-31 to `9905e61ca2`
(3219 commits, window proven by `scripts/pm/git-history.mjs`), 4 commits
moved the entry layout: `fbec216e2` (objectstack-ai#20695), `c23cfb346`, `9165d5cd4`,
`776d64cd3`. Every one of them touched both `package.json` and
`tsup.config.ts`. `src/index.ts` changed in 5 other commits, all of them
content (a new metadata kind, a cycle fix, schema edits, citation
re-anchoring), so it is not in the list.
`browser-reachable-entries.json` (touched by objectstack-ai#20695) is read only by a
lint gate, not by the console build, so it is not in the list either.
The triage control holds: no `console` entry reaches
`packages/spec/src`.

## Where the pins live (H3)

Before this PR, nothing asserted the `console` list. The only readers
were `dispatch-gates.mjs` fixtures, and `check-expected-skips.mjs`,
which reads outputs and `if:` but never paths. The pins go in
`check-ci-filter-parity.mjs`, the gate that already reads and pins the
filter job's lists, through the same YAML reader. Its battery (8) pins
the following:
- A head touching only `packages/spec/package.json` selects the job and
moves the key. The same holds for `tsup.config.ts`.
- The control: a head touching only `packages/spec/src/ui/view.zod.ts`
(a tracked file) does not select the job.
- Each way the filter and key can drift turns red on a synthetic tree: a
filter entry the key does not hash, a hashed path the filter does not
name, a pattern entry, a split key, a stale guard and a hashed guard.
There are four refusals, and the report path turns red over the
checked-in ci.yml with one hashed path dropped from the filter.

## A bounded in-place fix, declared

The two probe scripts were already `console` entries but not in the key,
so a head that changed the derivation could also get a green hit without
the new derivation ever running. The new rule, applied to the base
`ci.yml`, turns red on exactly those two (leg B below). It is the same
defect class and the same parameter, a mechanical edit to the same file,
and it adds no new verification surface, so it is fixed here. Cost: 1
commit in the 30-day window touched either script.

## Cost

- Selection of the job on a pushed head: 37 → 53 of the 3219 commits.
The key moves on 13 → 32 of them.
- The Version Packages PR bumps `packages/spec/package.json`, so its
runs now select the job (93 CI runs on that branch in the same 30 days).
The first push of each version rebuilds; later pushes with the same file
content hit that PR's own entry.
- The queue already runs this job on every `merge_group` build and
rebuilds most of the time (above), so the queue's cost barely moves.

## Reverse verification (from the committed state, `1bd002357`)

- Leg A, **the hazard itself**: filter widened, key not moved. `node
scripts/ablation-replace.mjs --file .github/workflows/ci.yml --anchor
NEW_HASHFILES_ARGS --replacement OLD_HASHFILES_ARGS --expect 3 -- node
scripts/check-ci-filter-parity.mjs` reported anchor 3 → 0 and blob
`24aac400cf2d` → `14c292d64529`. The gate printed "FAIL: ci.yml's
`console` filter and the console dist key are out of step", naming the
four unhashed entries, and exited 1. The restore brought the blob back
to `24aac400cf2d`, equal to HEAD, and `git diff HEAD` was empty.
- Leg B, the new rule over the base `ci.yml` (`bee75cebe6`): exit 1,
naming exactly `scripts/console-spec-probes.mjs` and
`scripts/assert-console-spec-injection.mjs`. That is the in-place fix
above.
- Direction observed: red, as expected.

## Gates

Derived from this diff with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` at `1bd002357` (57
commands). Exit codes and verdict lines are in the report on objectstack-ai#20765.
On-point results:
- `node scripts/check-ci-filter-parity.mjs`: exit 0, "OK: all 9
`console` entr(ies) are literal paths; 6 are build inputs the console
dist key hashes and 3 are declared guards; the key is spelled one way in
all 3 place(s)…"
- `node scripts/check-ci-filter-parity.mjs --self-test`: exit 0, 68
assertions.
- `pnpm check:pm-expected-skips`, `pnpm check:console-injection`, `pnpm
check:console-sha`, `pnpm check:workflow-status-functions`, `pnpm
check:workflow-step-name-quoting`, `node
scripts/check-self-test-workflow-commands.mjs` (both legs) and `pnpm
check:nul-bytes`: all exit 0.
- NOT MEASURED, prerequisite not met: `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:sourcemap-no-sources-content` need built package `dist/` trees.
This diff touches no package source; CI builds before them.
- ESLint, narrowed and proven: the population is `eslint.config.mjs`'s
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block, which includes both edited
`.mjs` files. `--format json` reports 2 files, 0 errors and 0 warnings.
The config enables no type-aware linting, so this diff cannot move an
untouched file's verdict, and `ci.yml` is not an ESLint input.
- `Console Pin Gate` runs on this PR's own head, because `ci.yml` and
the key's inputs moved. Its verdict is read from CI.
- Patch round at `f57066dd5` (comment and fallback text in
`check-console-injection.mjs` and `release.yml`, one `ci.yml` comment):
`dispatch-gates.mjs --commands` names the same 57 commands; reconciled
with `--ran` (53 run, 4 NOT MEASURED as above). `node
scripts/check-console-injection.mjs --self-test` exit 0 (44 assertions);
both `remedy()` branches exercised on a scratch dist.

## release.yml's key (seat ruling 5920200169, Q1 = B)

`release.yml` keeps its narrower key, and only its comment changes. The
two workflows no longer share dist entries. Following ci.yml would
rebuild the console on every Version Packages merge, and publishing a
cached console whose spec content lags is the existing cache design.

## Acceptance notes

- The prose this PR made stale in `scripts/check-console-injection.mjs`
and `release.yml` is corrected in the patch round (`f57066dd5`). Neither
the header nor the remedy fallback restates the key any more, so
`check-ci-filter-parity.mjs`'s hold on ci.yml's spellings is the only
copy that matters.
- Observation, not filed (a read-only inference with no measured reach):
`build-console.sh` copies the tracked `sdui.manifest.json` into the
dist, and that file is in neither the key nor the list. `a093ce3e2`
changed it without a pin move, so a cached dist would carry the older
copy until the pin moves. Carrier: none.
- objectstack-ai#20764 (the seat protocol watching non-required queue checks) and
making this job a required context are out of scope here, as triage
ruled.

Body edited by the `domain:spec` seat 2 PM
(`session_017VaLJnYwhPsanVCe9dMCJU`) after the patch round, from the
dev's wording in its report on objectstack-ai#20765.

---------

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

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

Projects

None yet

2 participants