feat(spec): pin every export by its .d.ts declaration text, and retire the 27 signature hashes - #18971
Conversation
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…egistries Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
Contract reviewServed-tier: 88/88 Tier was verified from the reviewing subagent's OWN transcript, not from its self-report: 88 per-turn model stamps read, all 88 equal to the constant above, zero stamps of any other value. A subagent cannot self-attest, because its ① Derived judgmentsThe ruling (
② Semver level
③ Boundary flags
Carrier clearBoth carriers are cleared on the strength of this record, in the protocol's order — record first, then the strip, so the gate is never open-with-nothing-behind-it: PR #18971 and card #16045. The reviewer's per-item verdicts, for audit:
Reviewer-declared instrument failures, carried rather than hidden: the Actions job-logs endpoint returned 403 through the proxy, so the new gate's CI result was bound through the job's CI on this head at 2026-09-18T0924Z: 31 success, 5 skipped, 0 failures, all converged, every run bound to this head. The earlier Implemented-by: VERDICT: PASS Generated by Claude Code |
…/main The declaration-text pins main brought in #18971 move with this branch's two facts: `source` becomes required wherever the predicate contract composes, and `cel` / `expression` now declare the `EvaluatedExpression` they always emitted. Exactly 8 distinct changed lines across all six files; the line count is those two facts repeated at every composing site. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
|
Maintainer ruling C — this PR is being reverted (skills seat, Recorded verbatim on #16045 (the card this PR closed): the maintainer read this PR's size and review trail with the skills seat and chose, of three options, 「C」 — revert, and make consumer compilation against Generated by Claude Code |
…napshot Resolves 13 modify/delete conflicts under packages/spec/api-surface-declarations/. Every conflict has the same shape: this branch deletes the file (no stage 2), main regenerated it (stage 3). Retiring that directory is the revert's whole purpose, so each conflict resolves to the delete. All 17 shards are gone from the merged tree -- the 4 main did not touch auto-resolved to delete already. The one other overlapping path, scripts/pm/dispatch-gates.mjs, auto-merged: main's hunk sits about 1600 lines from the reverted one. Verified on the merged tree rather than assumed: - no code, script, workflow, gitattributes or package.json entry references api-surface-declarations in any spelling; the only three mentions left are historical prose in .changeset release notes (17108, 18991, 19085), reported separately and deliberately not edited here. - of the 31 paths the reverted commit touched, none still carries a line that commit added; the four that differ from its parent are later, unrelated work main landed (lint.yml keeps #18889's step; check-published-files, dispatch-gates and regen-artifacts carry post-revert commits). - api-surface-signatures.json is back with its 27 hashes and, built from these merged sources, check:api-surface reports the public API surface and factory signatures unchanged -- so the restored pin is correct, not merely present. - check:generated reports all 15 artifacts up to date; main's count is 16, and 16 is what #18971 made it when it registered check:api-surface-declarations. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-Authored-By: Claude <noreply@anthropic.com>
… against both tarballs (objectstack-ai#18889) Fixes objectstack-ai#17080 Clause-②: yes (widening) Ruled **A** (issue comment 5643392133, decision batch objectstack-ai#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 objectstack-ai#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 objectstack-ai#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](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ON clause is derived (objectstack-ai#18938) Fixes objectstack-ai#18612 Clause-②: yes (narrowing) Retires `sql` and `relationship` from `CubeJoin`. A cube join declares WHICH object it reaches; the ON clause is **derived** from the declared relationship between the two cubes' objects and is never authored. Per maintainer ruling `5725370783` (director batch objectstack-ai#154 item 4, letter 2), ADR-0049 enforce-or-remove. The other remedy — executing the author's SQL — was declined by that ruling and is ⛔ not reopened here. ## What this head carries — round 3 closed the one gap The gap the earlier body described (`check:adr-0087-registration` RED on purpose, and a fence on `packages/spec/src/migrations/registry.ts`) is **gone**. The maintainer answered that fork with **A — lift the fence**, and the registration is now in-diff: | what | where | |:---|:---| | D3 semantic entry `cube-join-sql-and-relationship-retired` | `packages/spec/src/migrations/entries/semantic/18.*` | | D2 conversion `cube-join-sql-and-relationship-removed` | `packages/spec/src/conversions/registry.ts`, chained into `step18.conversionIds` | | `RETIRED_KEYS_BY_MAJOR[18]` gains `data/CubeJoin:sql` and `data/CubeJoin:relationship` | the two per-file retired-key entries | | the changeset's disposition marker | `<!-- adr-0087: registered cube-join-sql-and-relationship-retired -->` | `check:adr-0087-registration` and `check:migration-registry` are both **exit 0** on this head. Round 3 added three things beyond the registration: - **The artifact at rest heals at the boot door.** All three doors in `packages/metadata/src/plugin.ts` run `_convertArtifactForward` before the strict parse, so a cube persisted with the old `{ name, relationship, sql }` shape is converted rather than refused. Pinned by `analytics.test.ts` — *"a persisted cube heals at the door"* — with its own lit control and the per-cube notice paths. - **`name`'s describe now states the convention it always had**: the join KEY is the foreign-key field on the cube's own object, and the emission is `LEFT JOIN <name> <key> ON <base>.<key> = <key>.id`. - **The showcase join is re-keyed** `showcase_project` → `project`, matching `task.object.ts`'s `Field.masterDetail('showcase_project')`; `gap-fill.test.ts` pins every join key against the base object's real field map rather than against a literal. ## The same measurement also chose the retirement ROUTE The ruling says 「`retiredKey()` tombstones per the standing shape」. `CubeJoinSchema` is a `strictObject`, and for a strict shape AGENTS.md's standing shape is **strict deletion plus a `guidance` prescription**, not a `retiredKey()` tombstone — the route `MetricSchema.filters` took one shape over in this same file (`packages/spec/src/migrations/entries/retired-keys/18.data__Metric__filters.ts` states it in as many words). Measured both ways on this tree: - `retiredKey()` tombstones: `check:authorable-surface` **exit 1** — *"2 key(s) were tombstoned with no registered retirement"*, naming `data/CubeJoin:relationship` and `data/CubeJoin:sql` and demanding those exact lines in `RETIRED_KEYS_BY_MAJOR` (the fenced file). Probe reverted; tree hash restored byte-identical to HEAD. - guidance route: `check:authorable-surface` **exit 0**, adjudicating the two baseline deletions under the objectstack-ai#4650 proof 4 it prints itself — *"2 baseline deletion(s) since 84ba4a8 carry their own proof: data/CubeJoin:relationship — def reachable from the metadata-type roots; writing 'relationship' on it is REFUSED as an unrecognized key"*, and the same for `sql`. - ⏱️ Both readings above were taken by the dev in round 1 and re-taken by the at-tier contract review at head `e177aa2686`; this seat adopted that record at 2026-09-18T14:39Z (comment `5731599385`). ⛔ They are not this seat's own runs. Either route needs the registration; it is now in-diff, in the table above. The route choice is independent of that registration, and is called out here so an at-tier reviewer can overrule it cheaply. ## Acceptance legs, both readings **LIT — an authored ON clause must be refused, in words a JSON author reads** | | `CubeJoinSchema.safeParse({ name: 'other', sql: 'a.id = b.a_id' })` | | --- | --- | | before | **ACCEPTED** — parsed to `{"name":"other","relationship":"many_to_one","sql":"a.id = b.a_id"}` | | after | **REFUSED**, `unrecognized_keys`, message: *"…was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect… Delete the key. A cube join has no authorable ON clause: it is DERIVED from the declared relationship between the two cubes' objects, as a foreign-key equality."* | **DARK — a join that declares only its object must still parse** | | `CubeJoinSchema.safeParse({ name: 'other' })` | | --- | --- | | before | **REFUSED** — `sql` was required (`invalid_type` at path `sql`) | | after | **ACCEPTED** — `{"name":"other"}` | **Alias leg — `{ on: 'x' }`, read once before and once after** | | reading | | --- | --- | | before | ``Unrecognized key(s) on this cube join: `on`. Did you mean `on` → `sql`?`` | | after | ``Unrecognized key(s) on this cube join: `on`.`` followed by the derivation prescription, and **no rename suggestion** | `aliases: { on: 'sql' }` is deleted rather than left pointing at a retired key: an alias naming a key the shape cannot accept answers the author with a second rejection — the `triggerPhrase` failure `packages/spec/src/shared/strict-object.ts` records. `on` now carries its own `guidance` entry, and both directions are pinned. ## The census the ruling took, re-taken — and one correction The ruling recorded 「authored cube `joins` in hotcrm, objectstack examples and cloud — **0 files**」. Re-measured first-hand on this tree, **objectstack is not 0**: - `examples/app-showcase/src/data/analytics/showcase.cube.ts` authors **both** keys, including `sql: '${showcase_delivery}.project = ${showcase_project}.id'` — a live instance of the defect, an ON clause the runtime was silently replacing. Fixed here. - Seven more authoring sites in `packages/services/service-analytics`'s own test fixtures, found by `tsc` after the keys left `z.input`, not by grep. Two of them authored `relationship: 'belongsTo'` — a value the enum never declared, which is its own evidence that nothing validated or read the key. All fixed here. This does not move the ruling: those are in-repo producers, fixed in this same diff, and they are what the retirement checklist calls for. It does mean 「zero producers ⇒ no conversion is owed」 rests on the external census only, and that half was **not** re-measurable from here (hotcrm and cloud are other repositories). Consumer census, with a lit control, on this tree: - reads of a join's `sql` anywhere in source: **0** - reads of a join's `relationship` anywhere in source: **0** (`native-sql-strategy.ts` was checked by name: it does not read either) - lit control, reads of a join's `name`: **8** across `native-sql-strategy.ts`, `objectql-strategy.ts` and `analytics-service.ts` ## What else moved, and why - `packages/services/service-analytics/src/dataset-compiler.ts` **constructed** both keys per join (a constant `'many_to_one'` and a synthesised ON string). The literal now carries `name` alone; `parentAlias`, which existed only to build that string, is gone. No read site changes — `analytics-service.ts:1178` still reads `name` only, exactly as the ruling said. - The liveness ledger rows went **with** the keys (`packages/spec/liveness/analytics_cube.json`), which is the strict-deletion route's disposition and the opposite of the tombstone route's. `analytics_cube` drops 12 `dead` to 10; `state-counts.md` regenerated, README notes cell rewritten to describe the set it now has. - `content/docs/references/data/analytics.mdx` is regenerated, not hand-edited. The `CubeJoin` table is now one row and its description states the derivation — which is the docs half the ruling asked for. - `packages/spec/src/data/analytics-strictness-batchd.test.ts` keeps its batch-D pin that an **undeclared** join key is refused by name; the fixture drops the two now-retired spellings so the pin isolates what it always pinned. Three new pins beside it cover `sql`, `relationship` and `on`. ## Verification Two readings, kept apart on purpose — one is the reviewer's, one is this seat's. **① At-tier contract review, taken at head `e177aa2686`**, adopted by this seat at 2026-09-18T14:39Z (comment `5731599385`), run in its own detached worktree (fresh `pnpm install --frozen-lockfile`, heavy steps under `scripts/pm/os-verify-lock.sh`, exit codes captured before any pipe). All exit 0: spec `build` · `check:generated` (*"All 15 generated artifacts are up to date"*) · `check:authorable-surface` · `check:liveness` · `check:migration-registry` (*"225 semantic, 195 retired-key, 181 retired-def"*) · `check-adr-0087-registration` and `--self-test` · `check-changeset-no-major` · `check:spec-docblock-symbol-anchors` (*"3130 anchors across 1462 spec sources resolve"*) · eslint over the 11 changed source/test files · `@objectstack/spec test` **488 files / 14190 tests** · `@objectstack/service-analytics test` **112 files / 2403 tests** · showcase `gap-fill.test.ts` **13 tests** · typecheck for spec, service-analytics and the showcase · `check:exported-any`, `check:yaml-examples`, `check:dual-source-exports`, `check:entry-nameability`, `check:browser-reachable-entries`, `check:skill-examples`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`. That review — same adoption, ⏱️ 2026-09-18T14:39Z — returned **FAIL on one mechanical blocker and nothing else**, not a judgment defect. The REQUIRED context `TypeScript Type Check` was red at `e177aa2686` because `check:api-surface-declarations` landed on main at `d8b12fca97`, **after** this branch's merge-base, so the branch carried neither the gate nor `packages/spec/api-surface-declarations/`. **② This seat's own reading of the fix, taken from the GitHub API at head `7caf92189a`** (⏱️ 2026-09-18T14:59Z 取): commit `59bd587aea` merges `origin/main`, and `7caf92189a` regenerates the shards. The API reports that commit as `{"total":30,"additions":0,"deletions":30}` over exactly three files — `api-surface-declarations/data.txt` −12, `root.txt` −12, `system.txt` −6. A **pure deletion**: ⛔ not one line was added, so nothing was hand-written into a generated artefact. That is byte-for-byte the shape the review predicted (each reshaped declaration loses `sql: z.ZodString;` and the `relationship` enum block, propagated by type inlining). CI at this head, ⏱️ 2026-09-18T14:59Z 取: **0 failing check runs out of 33**. `Build Core`, `Dogfood Regression Gate`, `Temporal Conformance (live PG + MySQL)` and `Governed Surface Queue Guard` are success; `Lint & Repo Gates` is in progress; `TypeScript Type Check` and `Test Core` have not reported yet. ⛔ Not-yet-reported is **not** passing, and this PR is not landed on that basis. ⛔ Not a complete account of what CI runs here: the 50 artifact-roster families, the 11 declared wide-population families, the 6 path-scheduled CI jobs and the always-runs tail each sit outside any derived total above. Not measured anywhere: repo-wide `pnpm test` / `pnpm typecheck`, `check:dual-build-cjs-loads`, and the external hotcrm / cloud census (other repositories). **⚠️ Landing-order note, so nobody is surprised.** PR objectstack-ai#19024 (the maintainer's, `priority:p1`) reverts objectstack-ai#18971 and **deletes all 17 declaration shards**. Whichever of the two lands second must merge the other first; if objectstack-ai#19024 goes in ahead of this PR, the regeneration commit above becomes moot and its three files disappear with the rest of the snapshot. ⛔ That is a mechanical merge, not a defect in either diff. ## Acceptance notes Noted, not filed — observations, no card: - `packages/spec/liveness/analytics_cube.json` still records `public` as an access-control flag that gates nothing and `refreshKey.every` / `refreshKey.sql` as a caching block with no scheduler. Both are already recorded there with their measurements; ADR-0049 wants a decision on each, and neither is this card. Successor: whoever picks up the `analytics_cube` ledger's remaining `dead` rows. - `AnalyticsQueryRequestSchema` reaches `CubeJoinSchema` only through `CubeSchema`, so no REST request surface changes. Successor: none. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ank `source` (objectstack-ai#18638) Fixes objectstack-ai#15811 Clause-②: yes ## Rework round — the seat's three items, measured on the rework head (⚠️ NOT the current head — see 〈Base catch-up〉 below) Seat verdict objectstack-ai#18638 (comment) (REWORK on a PASSed contract review). Three items, nothing else re-opened. ### 1. The changeset and the ADR-0087 entry said something this diff makes false Both claimed the three union-member positions leave their sibling arm untouched. **Re-measured here**, base `00115a8442` vs head, parsing each value AS MOUNTED through `TraceSamplingConfigSchema`: | position | sibling arm | base | head | |---|---|---|---| | `RecordAlertProps.visible` | `z.boolean()` | `true` / `false` accepted | identical | | `ServiceLevelIndicator.successCriteria` | structured `{ threshold, operator, percentile? }` | accepted, **including an object carrying a `dialect` key** | identical | | `TraceSamplingConfig.composite[].condition` | `z.record(z.string(), z.unknown())` | see below | **narrowed** | At the tracing slot, six shapes the base accepted **through that arm alone** — measured: the base's `ExpressionInputSchema` refuses all six, so the record arm was the only thing admitting them — are refused at head: | authored `condition` | base | head | |---|---|---| | `{ dialect: 'cel' }` | accepted | refused | | `{ dialect: 'js', source: 'x' }` | accepted | refused | | `{ dialect: 'nope', source: 'x' }` | accepted | refused | | `{ dialect: 'cel', source: 5 }` | accepted | refused | | `{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }` | accepted | refused | | `{ dialect: 'zzz', foo: 1 }` | accepted | refused | Control that HITS: a structured filter carrying no `dialect` key — `{}`, `{ service: 'api' }`, `{ attributes: { 'http.route': '/v1/orders' } }` — is accepted at base and at head alike. Without it the six `refused`s would be a schema that refuses everything. ⭐ The narrowing is correct and load-bearing (it is what makes the ruled change non-inert at that slot) and is **not** removed. What changed is the **description**: the changeset now carries the table and its FROM → TO, and the migration entry's `surface` and `acceptanceCriteria` both name the wider sweep that slot needs — flag every `condition` object carrying a `dialect` key, not only the two spellings. A changeset becomes the CHANGELOG and an ADR-0087 entry becomes the migration ledger; neither may ship a false sentence. ### 2. The published reference page `.refine()` has **no JSON Schema projection** — measured against zod 4.4.3: `z.toJSONSchema` returns byte-identical output for the plain record, the refined record and the aborting refined record (`{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}`). So regenerating alone could never move that TYPE cell, and hand-editing the page is forbidden and would be reverted. The fix is at source: the slot's `.describe()` now states the rule, and `gen:docs` republishes it. `content/docs/references/system/tracing.mdx` now reads: > Condition for this strategy — a structured filter object, or a CEL predicate an engine evaluates.⚠️ The two are told apart by the `dialect` key: a structured filter must NOT carry one, and an object that does is judged as an expression — so it needs a dialect this platform evaluates and a non-blank `source` … `{ dialect: 'cel', ast: … }` with no `source` is refused here.⚠️ The type cell still renders that arm as a plain record of string to any, and that is **faithful to the JSON Schema this repo publishes** — which is itself wider than the zod schema, for every `.refine()` in the spec, not only this one. Making the page contradict the artifact beside it would be worse. Reported as an out-of-scope finding rather than repaired here. ### 3. The two unpinned message cells — the fix was in the schema, not only in a test Measured at the slot, on head as it arrived: - `{ dialect: 'cel', source: '' }` → one top-level `invalid_union` with the bare **`Invalid input`**; the published sentence appeared only inside nested arm issues; - `{ dialect: 'js', source: 'x' }` → refused with the **「needs a non-blank `source`」** sentence, which misnames the fault: that value's `source` is fine, its dialect is not. Root cause, measured: zod 4.4 reports the ONE arm that did not abort, else `invalid_union`. The record arm's `.refine()` was **non-aborting**, so it was the surviving arm for every expression-shaped refusal here and answered for all of them — and it answered with the other arm's sentence. ⇒ The repair is in the schema, not only in a test: the refine becomes **aborting**, and its message becomes the arm's own rule (module-local, ⛔ not a new published export). Ablation of the accept set: the refused set is **identical** with and without `abort` — both measured over the ten-value corpus above, so this is a message change and not a second narrowing. After it, the slot answers exactly what the other 35 answer, and exactly what the migration entry's own acceptance criteria promise: | authored `condition` | before | after | |---|---|---| | `{ dialect: 'cel', source: '' }` / `' '` | `invalid_union` @ slot, `Invalid input` | **one `custom` issue @ `…condition.source`, the published sentence** | | `{ dialect: 'cel', ast: … }` | `custom` @ slot, published sentence | `invalid_union` @ slot, published sentence | | `''` / `' '` (bare) | `invalid_union` @ slot, published sentence | unchanged | | `{ dialect: 'js', source: 'x' }` | `custom` @ slot, **published `source` sentence** | `invalid_union` @ slot, `Invalid input` — no longer blames `source` | Pins, in `packages/spec/src/system/tracing.test.ts`: the accept set (six refusals + the accepting control), both blank spellings' published sentence and its exact `code`/`path`, the `ast`-only and bare-string cells, the **negative** (a non-`source` fault is not answered with the `source` sentence), and the `.describe()` the reference page renders. And `evaluated-slot-population.test.ts`'s published-sentence pin now runs all **three** refused spellings at all 36 positions instead of only the `ast`-only one — 108 cases, all green. That is what would have caught this slot in the first place. ### Not re-opened⚠️ **`Clause-②` is now `yes`, re-declared by the seat under ruling A (batch objectstack-ai#155 item 3, `5725503887`, maintainer 「同意」 2026-09-18T05:13Z).** The 28-input strict-subtype measurement is NOT overturned — the conclusion drawn from it is: 「a member replaced on a key line is a change to a published contract … the same review a narrowing owes **regardless of the tell**」. ⇒ a false tell was never the question; a narrowing owes the at-tier review on its own. `minor` + BREAKING banner + ADR-0087 disposition stay. `printCelAst`, the package-internal helper and the 36-position census stay. `packages/spec/api-surface/shared.json` and `export-origins/shared.json` are still **hash-identical to base** (`git hash-object`: `cf260910f1…` / `0429ff67a6…`), and `git diff --stat 00115a8..HEAD -- packages/spec/api-surface packages/spec/export-origins` is empty. ### Gates, re-derived on the rework head (⚠️ two figures superseded — see the note under it) `node scripts/pm/dispatch-gates.mjs --commands` on the merged head, every exit code recorded as it ran, reconciled with `--ran`: **110 derived, 104 run, 6 NOT MEASURED, 0 unrun** (`--ran` exit 0). `pnpm --filter @objectstack/spec build && test && typecheck` green — 486 files / 14016 tests; `@objectstack/formula` 30 files / 871 tests, typecheck green. `check:generated`: all 15 artifacts up to date after the `origin/main` merge and the final rebuild.⚠️ **Two figures in the paragraph above are readings on an EARLIER head.** They are pinned here, ⛔ not restated as current and ⛔ not retyped. - The `110 derived, 104 run, 6 NOT MEASURED, 0 unrun` reconciliation was taken on `e892c86e271`. The head is now `34a63b9d583`. ⛔ It is not re-derived here — read it as the reading it was. - **`all 15 artifacts` is superseded.** objectstack-ai#18971 registered a new generated-artifact family and the registered count is now **16**. Measured: the `GATED` array in `packages/spec/scripts/check-generated.ts` carries **16** entries at `70407326463d`, at `e892c86e271` and at `34a63b9d583` alike — with a dark control on a non-existent array name extracting 0 lines — so the `15` predates all three heads rather than describing any of them. The `15` is left above verbatim as the historical reading. - Current reading, on `34a63b9d583`: `check:generated` **exit 0 — all 16 generated artifacts up to date**, working tree clean. ### Base catch-up — `origin/main` merged, four deferred artifacts regenerated Merged `origin/main` `b14610255101` at `ab00016c221`, regenerated in `34a63b9d583`. The `os-regen` driver **deferred** on four routed both-sides paths — `api-surface-declarations/automation.txt`, `data.txt`, `ui.txt` and `content/docs/references/ui/component.mdx` — and ⭐ **a deferral silently keeps ONE side**: in the merge commit, main's token reads **0** on all four while the branch's side is intact. Main's side was restored and all four re-derived from the merged tree; on the head each path carries **both** counts, against a positive control that hits on every blob of every path (⛔ a control that fires on one shard certifies one shard). The two ⛔ MIXED paths are deliberately not routed to the driver and were hand-resolved. `packages/spec/src/migrations/registry.ts`: generated regions stripped, the hand-written remainder byte-identical across base, both sides and the merge, line counts exactly additive (17142 + 121 + 74 = 17337), and **both** sides' migration entries present by id. `packages/spec/src/ui/component.zod.ts`: additive text merge (3750 + 4 + 45 = 3799), its `.superRefine()` untouched. Refinement census over non-test `packages/spec/src`, counted by occurrence rather than by matching line: `.refine(` 59 base / 60 branch / 59 main / **60 head**; `.superRefine(` 80 at all four; `.check(` 4 at all four. ⛔ Nothing deleted, nothing weakened. Non-zero exits, all declared: - six **exit 3 · PREREQUISITE NOT MET** (`check:doc-formula-expressions`, `check:doc-security-posture`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`) — each refuses an unbuilt workspace closure and says so. NOT MEASURED, not findings; - `check:skill-examples` **exit 1** — the same class in exit-1 clothing (`packages/client-react/dist` holds no `.d.ts`); - `check:react-declaration-parity` **exit 1** — run as CI runs it, `MANIFEST="$PWD/sdui.manifest.json" … --strict`: `111 spec-only divergences, 1 blocks missing from the registry`. Control: the identical command at base `00115a8442` prints **the same two numbers**, so it is pre-existing and this PR moves neither. One gate went red on this round's own work and is fixed: `check:doc-authoring` refused an internal issue id in customer-facing spec text — the `(objectstack-ai#15811)` this round put in the tracing `.describe()`. Removed, page regenerated, gate green; the same gate at base is green, so the id was the only offender. `node scripts/pm/check-clause2-carriers.mjs --pair 18638` — **exit 4**, and the dual-carrier row C1 is gone: only **C5** remains, with the same two false tells (`ui/action.zod.ts:833` T2, `ui/component.zod.ts:1595` T1). ⛔ Reported, not acted on; the matcher repair is objectstack-ai#18640's. Rework round authored by the `domain:spec` execution seat, session `session_01LvwGppdonww4zGLWZo5rho`. Decision batch objectstack-ai#122 item 2 generalised the evaluated-slot rule: `EvaluatedExpressionInputSchema` now composes into **every** slot an engine evaluates, while `ExpressionSchema` / `ExpressionInputSchema` stay the persistence contract (`source` OR `ast`) by item 2 of the same ruling. An `ast`-only envelope and a `source` that is blank after trimming — through the envelope key or the bare-string shorthand — are refused at the door instead of parsing, registering, and faulting at run time. ## The population was re-derived, not inherited The census in the card is six days old and `shared/expression.zod.ts` moved after the ruling, so the 36 figure was treated as a premise. Re-derived by **identity** on this branch's base `00115a8442` — a negative lookaround on identifier characters, because the bare substring also fires inside `CronExpressionInputSchema`, `TemplateExpressionInputSchema` and `EvaluatedExpressionInputSchema`, which is the trap that inflated triage's own reading on this card (32 files, five of them Cron-only): | reading | count | |---|---| | declaring source lines mounting the schema (non-test, non-comment) | 34 | | of those, file-local alias consts mounting 2 slots each | 2 | | **declaring positions** | **36** | | lit control — identity hits in the definition file | 7 | | the same file counted by bare SUBSTRING | 17 | | dark control — `ZzzNoSuchSchema` | 0 | That 7-versus-17 gap in one file is the trap itself, in miniature. Identical to the measured census (objectstack-ai#15811 (comment)), position for position. Two aliases: `ui/action.zod.ts` `ActionConditionInputSchema` (mounts `visible` + `disabled`) and `system/settings-manifest.zod.ts` `SettingsVisibilityInputSchema` (mounts the specifier and manifest `visible`). Three positions reach the schema as a union member rather than head-of-declaration. `PredicateInputSchema` is a plain alias of `ExpressionInputSchema` with zero slot users; it stays wide with the schema it aliases. ## Two defects found while measuring, both fixed here **1. The narrowing was INERT at `TraceSamplingConfig.composite[].condition`.** That slot is `z.union([z.record(z.string(), z.unknown()), …])`, and a bare record arm accepts `{ dialect: 'cel', ast }` as an ordinary record — so swapping the other arm changed nothing. Measured: after the swap and before this fix the slot still answered `success: true` on the `ast`-only envelope, while its 35 siblings answered `false`. The structured-filter arm now declines an object carrying a `dialect` key, which is an expression attempt whatever it got wrong. Shipping the swap alone would have been a declared-but-unenforced narrowing. **2. Four positions refused with zod's bare `Invalid input`.** Where the declaration wraps the evaluated schema in a WIDER union — a boolean beside it on `action.visible` / `action.disabled` / `RecordAlertProps.visible`, a structured object beside it on `ServiceLevelIndicator.successCriteria` — the outer union reports `invalid_union` at the slot and the inner union's sentence never surfaces. `evaluatedExpressionUnionRefusal` gives those unions the published sentence. It is deliberately stricter than the inner map it complements: it answers only for a blank string or an object carrying `dialect`, so a malformed threshold object is not blamed on `source`. It lives in `shared/evaluated-slot-union.ts`, **package-internal** and absent from both barrels, on the `union-branch-policy` convention: a narrowing PR that grows the published export surface widens on a second axis, so `api-surface/` and `export-origins/` do not move for it. ## Item 3 — the printer path is real, and measured The ruling asked for the lossless direction 「where the dialect has a printer」 before falling back to a structured TODO. Measured rather than assumed: `@marcbachmann/cel-js` ships `serialize`, and `cel-engine.ts` already uses it for its own scope rewrites. So `@objectstack/formula` gains **`printCelAst(ast)`**, the inverse of the existing `parseCelToAst`, and the migration entry prescribes it by name instead of describing a capability nobody can call. Measured round-trip, six sources, each re-evaluated on the same scope: ``` record.amount > 10 -> identical bytes record.priority == 'urgent' -> record.priority == "urgent" 'org_admin' in current_user.positions -> "org_admin" in current_user.positions record.a == 1 && (record.b != 2 || record.c > 3)-> identical bytes size(record.tags) > 0 -> identical bytes ``` Lossless about MEANING, not bytes — the printer re-renders from the parse tree, so quote style normalises. Dark controls, all four throwing rather than inventing a source: `{}`, `null`, `{ type: 'nope' }` and a plain string each raise `Unknown AST operation`. `printCelAst` converts that into `null` and additionally requires the printed text to parse back through the platform's own bounded `parseCelToAst`, so it can never widen what this platform evaluates. Where the printer answers `null`, and for every blank `source`, the ADR-0087 D3 entry `evaluated-expression-slots-source-required` is the structured TODO — naming the object, the field and the slot, and splitting the judgment by fail policy, because removing a key is safe on the fail-soft half of the population and a silent disclosure on the fail-closed half. **Why this is a D3 entry and not a D2 conversion, now that a printer exists.** The conversion layer lives in `packages/spec`, which is dependency-free by Prime Directive objectstack-ai#2 and carries no engine — `packages/formula/src/normalize.ts` states the same boundary from the other side. A conversion that had to call the CEL printer could not live where conversions live, and one that guessed without a printer would be the platform inventing a predicate. ##⚠️ Deviation: graded `minor`, and the ruling said `major` Item 3 ordered a 「`major` changeset」. `scripts/check-changeset-no-major.mjs` forbids a `major` marker during the launch window, because the fixed group versions in lockstep and one `major` promotes all ~70 packages to a whole-stack major — which is a release act reserved to the maintainer. The guard's own header names the two carriers the convention uses instead, and both are present: the **BREAKING** banner in the changeset body and the ADR-0087 disposition line. The ruling's substance ships; only the marker differs, and it differs because a repo gate forbids the marker. Flagged rather than chosen silently. ## Item 4 — the mechanical acceptance surface objectstack-ai#17630 is closed and its widening is live on this base: discovery in `packages/qa/dogfood/test/expression-conformance.test.ts` matches a roster name by identity anywhere on a line, attributes it to the `field:` it mounts, and resolves file-local aliases. Both `ExpressionInputSchema` and `EvaluatedExpressionInputSchema` are on that roster, so every one of the 36 positions stays discovered across the swap, the ledger's `file:Schema.field` cover keys are unchanged, and the `SCAN_CONTROLS` floors (head 37 / inline 3 / alias 2) are unaffected — the swap changes the identifier, never the syntactic shape. No ledger row's `failPolicy` moves: the column records what the EVALUATOR does with a bad expression, and no evaluator changed. ## Clause-② carrier readings, reported rather than acted on⚠️ **Superseded in part by ruling A (`5725503887`), and the seat has since acted.** C5 below reads three widening tells against a `Clause-②: no` that no longer stands: the declaration is now `yes`, so the C5 tell no longer gates this PR and the at-tier review does. C1 (the split dual carrier) is also closed — the seat hung `needs:contract-review` on BOTH card objectstack-ai#15811 and this PR at 2026-09-18T09:52:56Z / 09:52:58Z. ⛔ The matcher was NOT touched and no C-class licence card was opened; ruling A refuses both by name. The readings below are kept unedited as the record of what was measured at the time. `node scripts/pm/check-clause2-carriers.mjs --pair 18638` — **exit 4**, two rows at the time of writing (re-read on the rework head: C1 has cleared, C5 stands — see the rework section above). ⛔ Neither carrier is touched from here; this is the reading, not a verdict. - **C1 — the dual carrier is split.** `needs:contract-review` is on card objectstack-ai#15811 and NOT on this PR. That is the state as found; the seat that owns the gate hangs or clears both sides in one stroke. - **C5 — three widening tells against `Clause-②: no`.** One was real and is gone: the new published export `evaluatedExpressionUnionRefusal` in `api-surface/shared.json`, removed by moving the helper package-internal (above), so the published surface is byte-unchanged by this PR. The remaining two are **false**, and both for the same reason — the matcher fires on an ADDED LINE that has the shape of a widening, and these two lines were added because an options object was appended to a union that gained no member: - `ui/action.zod.ts` `ActionConditionInputSchema` — read as T2 「a new member of a closed set」. The union has the same two members before and after; what is new on the line is `, { error: … }`. - `ui/component.zod.ts` `RecordAlertProps.visible` — read as T1 「a new key on a Zod object schema」. `visible` existed before this PR; the line moved for the same options object. Per the gate's own instruction a false tell is repaired in the matcher (`scripts/pm/check-widening-tells.mjs`, with a `--self-test` case pinning the shape) or filed as its own card. Repairing a `scripts/pm/**` matcher is outside this card's surface, so it is filed rather than done here — see the report's `out_of_scope_findings`. ## Tests `packages/spec/src/shared/evaluated-slot-population.test.ts` is the new pin, in two halves because either alone is a green that proves nothing: - **structural** — no declaring position in `packages/spec/src` still mounts the persistence schema on a code line, with a lit control (the scan does find the name in the definition file and the barrel), a dark control, and an explicit assertion that the Cron / Template / Evaluated siblings do not leak in as substrings; - **behavioural** — all 36 positions parsed AS MOUNTED, refusing all three refused spellings and carrying the one published sentence, plus an assertion that the table reached exactly 36 positions so a position that stops being reachable reds instead of silently leaving; - **controls** — `ExpressionSchema` / `ExpressionInputSchema` / `PredicateInputSchema` still ACCEPT both shapes, and a healthy predicate still parses at all 36 (the settings pair gets the predicate its own closed grammar accepts). `packages/formula/src/print-cel-ast.test.ts` pins the printer's two claims, including seven dark-control inputs. Three existing pins were rewritten rather than relaxed — each pinned exactly the arm this PR deletes: - `system/settings-manifest.test.ts` 「an `ast`-only envelope is opaque at this layer」 now pins the refusal, and asserts the settings GRAMMAR message is *not* the one raised, so the two refusals stay independent; - `ui/action.test.ts` 「rejects composition with an AST-only visible loudly (ADR-0078)」 — ADR-0078's promise is unchanged, but the refusal moved from the `requiresFeature` lowering to the slot, so it now holds with **and without** the flag. A second case pins that objectstack-ai#17631's shape (a blank `source` composed into `( ) && features.admin == true`) can no longer reach the lowering at all; - `ui/view-form-features-root.test.ts` 「documented boundary」 now asserts the refusal comes from the evaluated-slot rule and not from the features-root scanner this file is about. **Repo census for the migration:** zero authored occurrences of either refused spelling outside `packages/spec`'s own refusal fixtures, across `packages/`, `examples/`, `content/` and `skills/`, against a lit control that hits. Nothing in this repository needs rewriting. ## Acceptance notes - `PredicateInputSchema` (`shared/expression.zod.ts`) remains a plain value alias of `ExpressionInputSchema` with zero slot users. Left wide deliberately — it aliases the persistence contract. Noted, not filed; carrier is the ledger's own limit 2, already written up there. - `celEngine.evaluate` on `{ dialect: 'cel', source: '' }` answers with the AST-only message rather than an empty-source one. Message accuracy only; the verdict is correct. Unchanged here, still uncarried. Authored by the `domain:spec` execution seat, session `session_01LvwGppdonww4zGLWZo5rho`, under the dispatch claim objectstack-ai#15811 (comment). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… size predicate in check-governed-merges --test, the same reading in dispatch-gates, one rule line in SKILL.md and landing-operations (objectstack-ai#19033) Fixes objectstack-ai#19012 Clause-②: no ## Maintainer ruling (verbatim, 2026-09-18) > 「还有应该完善skills,修改代码量超过某个行数(比如5000)就应该人工审核。」 Read as: a pull request whose changed line count — GitHub's `additions + deletions` on the PR, generated files INCLUDED — exceeds 5,000 lands only by a human merge, at the same terminal as governed text (ACCEPT on the card, `needs-user-decision` on the PR, a final 维护者速读, review requested from `GOVERNED_APPROVERS`); no seat flips it ready or arms auto-merge. 5,000 is the ruled default (「比如」), declared once as `HUMAN_MERGE_LINE_THRESHOLD` in `scripts/pm/check-governed-merges.mjs`, so it moves by one word from the maintainer and one edit. The case that prompted it, PR objectstack-ai#18971 (+238,310 / −119, of which 237,706 lines were regenerated artefacts), is the first PR the rule governs — an exemption for generated files would exempt exactly it, so there is none. ## What changed 1. **`scripts/pm/check-governed-merges.mjs` — the SIZE predicate.** `--pr N` reads `additions` / `deletions` off the same `GET /repos/OWNER/REPO/pulls/N` that gives `changed_files` (a PR object missing the pair is a refusal on exit 1 — never a size of zero, never a "not governed" answer); `--branch REF` counts the same merge-base range with `git diff --numstat --no-renames` (a binary file is 0 lines, as GitHub counts it); `--test PATHS` takes `--additions N --deletions N` as a pair, or prints `size: NOT MEASURED` on stdout naming the modes that read it. Either limb exits on the GOVERNED code 3, so every caller that already routes 3 to the human terminal routes an oversized PR there without a new code; `--json` carries `size` and `humanMerge` (`governed` stays the path limb). A certified generated-artifact regeneration lifts the PATH off the register and lifts nothing from the size. The queue guard's `testVerdict(paths)` reading is unchanged (no size handed in ⇒ the path answer as before). 2. **`scripts/pm/dispatch-gates.mjs` — the same reading at dispatch time.** With no paths (the derived run) it prints `Changed lines — N (+a / -d; generated files INCLUDED) vs the human-merge threshold 5000: under` or `⛔ OVER — this PR lands only by a HUMAN MERGE …` beside the tier verdict (human and `--tier` modes), the count on stderr with the rest of the provenance, and `changedLines` in `--json`. The count is `--numstat` off the merge base against the working tree plus untracked files counted from disk (under-derivation refused, like the path list). An explicit path list carries no diff and prints `NOT MEASURED`, never a silent under. The threshold is imported from the gate — one declaration, no second copy. 3. **Rule text.** `.claude/skills/pm-dispatch/SKILL.md` gains one line beside the four-piece-terminal trigger (line 608, 111 B): 「改动 >5000 行(含生成物)同换终局四件套,⛔ 无事实层例外;读数 = PR additions+deletions。」 `references/landing-operations.md` line 26 folds the size limb into the pre-check row, now spelled `--pr N` (which reads paths and size in one call), 117 B, ceiling unchanged at 69. The SKILL.md ceiling rises 812 → 813 in `scripts/pm/check-skill-line-ratchet.mjs` under the ratchet's own maintainer exit, the ruling quoted in the entry (the 811 → 812 precedent's form). ## Readings — before / after, measured | reading | before (`43f476688`) | after (this head) | |---|---|---| | `check-governed-merges.mjs --pr 18971` (live API through the proxy) | exit 0 — `✅ NOT governed — ordinary queue landing applies` | exit 3 — `⛔ HUMAN MERGE — 238429 changed line(s) (+238310 / -119) > 5000` | | `--pr 18994` (2 files, +15 / −1) | exit 0 | exit 0 — `size: 16 changed line(s) (+15 / -1) ≤ 5000 — under the human-merge threshold` | | `--pr 18921` (SKILL.md, +6 / −6) | exit 3 GOVERNED | exit 3 GOVERNED, plus `size: 12 changed line(s) … under` | | `check-governed-merges.mjs --self-test` | 328 assertions, 26 batteries | 369 assertions, 27 batteries (new battery: the SIZE predicate, floor 30) | | `dispatch-gates.mjs --tier` (no paths, this worktree) | no size line | `Changed lines — 722 (+691 / -31; generated files INCLUDED) vs the human-merge threshold 5000: under.` | | `dispatch-gates.mjs --tier packages/spec/src/index.ts` | no size line | `Changed lines — NOT MEASURED: a path list carries no diff to count …` | | `check:pm-dispatch-gates` (detached, `tail --pid`) | 1849 cases (the dispatch's reading at `43f476688`) | 1862 cases pass (795.6 s, detached; +13 cases) | | `check:pm-skill-ratchet` | SKILL.md 812 / 812 · landing-operations.md 69 / 69 | SKILL.md 813 / 813 · landing-operations.md 69 / 69 | Self-test pins on the threshold: exactly 5,000 changed lines is under; 5,001 is over; the +238,310 / −119 pair reads 238,429 and is over; a certified pure regeneration over the threshold still lands by a human merge; the verdict is byte-identical through `--branch` and through `--test` once the same list and numbers are handed in. ## Line budget (measured) - `SKILL.md`: 812 → 813 lines; ceiling 812 → 813 (maintainer exit). A fold was not available: 0 of 598 adjacent bullet pairs merge under the 120-byte cap (smallest 123 B); the trigger line (607) stands at 118 B; the rule's shortest self-contained form is 111 B; deleting a ruled clause is refused on the state-machine precedent. - `references/landing-operations.md`: 69 → 69 lines (line 26: 118 B → 117 B). - `check:pm-skill-id-lint`: 27 files clean (no issue-ID citation in either line). ## Gates (this head; exit codes captured before any pipe) Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the worktree at `7fdd61ca0` (42 commands; change set 5 paths, 724 changed lines by its own reading), every one run with `cmd > log 2>&1; status=$?` and reconciled with `--ran`: ```text node scripts/check-ci-filter-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-scripts-symbol-anchors.mjs :: exit 0 node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-skills-token-ratchet.mjs :: exit 0 node scripts/check-skills-token-ratchet.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 node scripts/pm/check-governed-queue-guard.mjs --self-test :: exit 0 node scripts/pm/check-harness-current.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:declared-population-live :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pm-expected-skips :: exit 0 pnpm check:pm-governed-prose :: exit 0 pnpm check:pm-half-states :: exit 0 pnpm check:pm-skill-id-lint :: exit 0 pnpm check:pm-skill-ratchet :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 ``` `dispatch-gates --ran`: **42 derived, 42 run, 0 NOT-MEASURED, 0 UNRUN** (verdict line: `✓ dispatch-gates --ran: 42 derived famil(ies) accounted for — 42 run, 0 NOT-MEASURED`). `check:pm-dispatch-gates` ran detached (`nohup` + `tail --pid`, 795.6 s on this box): `✓ dispatch-gates self-test: 1862 cases pass.` `check:pm-governed-merges`: `✓ check-governed-merges --self-test: 369 assertions`. `check:doc-formula-expressions` exited 3 (PREREQUISITE NOT MET: `@objectstack/formula` / `@objectstack/lint` not built) on the first pass; both were built under `scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0, 152 s held) and the gate reran green — the exit 3 was never a measurement. NOT MEASURED locally, by the derivation itself (CI-only, value-bearing argv): `scripts/check-shard-attestation.mjs --emit …`, `scripts/check-test-completeness.mjs …`, `scripts/pm/check-half-states.mjs --format=markdown --provenance=…`; plus the 11 wide-population families and the 50 artifact-roster families CI runs on every PR, outside the derived total by design. `pnpm lint` (repo-wide eslint) is CI-owned and was not run here. No package build/test is owed: the diff touches no `packages/**` file (no ①/② in the local verification scope), so the only lock-wrapped run was the formula/lint build above. Line-budget after the final commit (`7fdd61ca0`): `check:pm-skill-ratchet` — `.claude/skills/pm-dispatch/SKILL.md is 813 lines (ceiling 813; headroom 0)`, `references/landing-operations.md is 69 lines (ceiling 69; headroom 0)`; `check:pm-skill-id-lint` — 27 file(s) clean. ## Deviations from the dispatch brief 1. Mechanism assumption 1 said a failing size read exits PREREQUISITE NOT MET (3). Under `--pr`, 3 already means GOVERNED — the file's own rule is that no invocation carries both meanings — so a PR object without the pair is a REFUSAL on the derivation code 1 (a stated refusal, never 0, never a size of zero). The ruling's intent (never read as "not governed") is kept. 2. "812 / 812 — fold or pay": measured, neither was available (above), so the SKILL.md line lands under the ratchet's own maintainer exit (812 → 813), the form the 811 → 812 entry took. The hunk sits at :608, disjoint from PR objectstack-ai#18903's bands (:509–:525, :633–:675) and from the two PRs that landed on SKILL.md meanwhile (merged into this branch; the line is still there once). If the seat prefers the follow-up route, drop commit 3's SKILL.md hunk and the ratchet entry together. 3. `--branch` derives the size itself (`--numstat` on the range it lists) rather than taking passed-in numbers; the flags beside a deriving mode (`--pr`, `--branch`) are refused as two readings of one number, the way two mode flags are. 4. `dispatch-gates.mjs`'s self-test pins a NAMED census of live population markers by file and line; the import block moved this file's own `inherited-population` marker from :702 to :705, so that one row is updated — the census exists to be updated exactly this way. ## Acceptance notes - to file (class b — a declared contract the queue cannot yet hold): the queue guard's `merge_group` leg reads the PATH register only; a seat that skips the landing pre-check can still enqueue an oversized PR. Dedupe words: `queue guard size threshold`, `merge_group additions deletions`, `check-governed-queue-guard 5000`, `human merge line count`. - to file (class b): AGENTS.md §7 lists "two classes of PR never enter this path on green alone" (governed surface; Version Packages) — the ruled third class is missing from the rules layer. Dedupe words: `AGENTS.md green alone third class`, `5000 lines human merge AGENTS`. - noted, not filed: the post-merge sweep (default mode of `check-governed-merges.mjs`) lists governed-surface merges only; an oversized PR that landed through the queue is not listed. 承接者: the skills seat, together with the queue-guard follow-up above. - noted, not filed: `check:doc-formula-expressions` exits 3 (PREREQUISITE NOT MET) on a fresh worktree until `@objectstack/formula` and `@objectstack/lint` are built — by design of that gate; built under the verify lock here and rerun. 承接者: none. ## 维护者速读(草稿) **改了什么**:落地前检 `check-governed-merges.mjs` 新增「体量」判据:PR 的 additions + deletions 超过 5000 行(含生成物)⇒ 只能人合,与受管面走同一终点;`dispatch-gates` 在派发/认领时就把同一读数印在 tier 行旁;SKILL.md 与 landing-operations.md 各落一行规则。阈值只声明一次(`HUMAN_MERGE_LINE_THRESHOLD = 5000`),改它是一个词。 **为什么改**:您 2026-09-18 的裁决。触发案例是 PR objectstack-ai#18971(+238,310 / −119,其中 237,706 行是生成物)只凭 AI 审查就经队列合入;生成物不豁免,否则恰好豁免它。 **风险与代价(含回滚)**:大 PR 的落地从「席位挂 auto-merge」变成「等您点一下」,每张超 5000 行的 PR 多一次人工动作;回滚 = revert 本 PR(纯脚本 + 两行规则文本,无发布物)。已知缺口:队列守卫的 merge_group 腿尚未读体量,眼下靠席位跑落地前检;已列为后续单。 **席位意见**:(留空) **你要做的**:确认 5000 这个默认值(「比如」)是否就是您要的;是 ⇒ 人合本 PR;要改数字 ⇒ 说一个数即可。 --- _Generated by [Claude Code](https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…erge sweep lists one — the SIZE limb reaches the queue (objectstack-ai#19175) Fixes objectstack-ai#19036 Clause-②: no ## What this PR does The maintainer's 2026-09-18 ruling — 「修改代码量超过某个行数(比如5000)就应该人工审核」 — landed seat-side in PR objectstack-ai#19033 (`HUMAN_MERGE_LINE_THRESHOLD = 5000`, `testVerdict(paths, { size })`, `sizeVerdict`, `landsByHumanMerge` in `scripts/pm/check-governed-merges.mjs`). The merge-queue guard's `merge_group` leg kept handing the predicate no size, so a PR over 5,000 changed lines that a seat enqueued still merged — exactly what PR objectstack-ai#18971 did (+238,310 / −119, nothing governed). This PR carries the limb to the two places the card names. **Queue guard (`scripts/pm/check-governed-queue-guard.mjs`)** — a fourth leg, `runSizeGuard` / `sizeGuardVerdict` / `renderSizeVerdict`: - reads every queued pull request's `additions` / `deletions` off the same pull object the head read uses — `makePullReader` grew a `size` field (through the sibling's own `pullSizeFrom`), no second endpoint, no new workflow scope (`pull-requests: read` already covers the pull object; the workflow file is untouched and the self-test pins that no `issues:` scope appeared); - judges it through the sibling's IMPORTED predicate: `testVerdict([], { size })` is the size limb alone (the path limb is the governed leg's question, already answered on the lifted rows), and `landsByHumanMerge` reads it — this file declares no threshold and spells no comparison, pinned against its own source; - REFUSES on a new exit code `EXIT_REFUSED_OVERSIZED = 8`, printing the two numbers, their sum, the threshold, the limb (SIZE), the source (`GET /repos/{o}/{r}/pulls/{n}`) and the one remedy — a human merge (the ruling quoted untranslated; it never advises making the diff smaller); - an unreadable size (the pull read throws, or the object carries no pair) is `EXIT_REFUSED_SIZE_UNREADABLE = 9`, fail-CLOSED — this read DECIDES, unlike the PR head, which has decided nothing since 2026-09-04; - the `pull_request` leg is silent and read-free (renders `''`), so that leg's output stays byte-identical; - the three-leg exit precedence is a pure function, `groupExitCode`: governed, then size, then carrier — every block is always printed, one code exits. **Post-merge sweep (`check-governed-merges.mjs` default mode)**: - `classifyCommit` is now `landsByHumanMerge(testVerdict(paths, { size }))`, so a landing is an entry when EITHER limb fired — a governed merge as before, and an oversized landing with no governed path at all; - the size is read LOCALLY off the landed diff: `commitChanges` runs one `git diff-tree --numstat --no-renames -m --first-parent` per mainline commit (replacing `commitPaths`' `--name-only`; the self-test pins the path list byte-identical against `--name-only` on a real fixture, a merge commit read against its first parent, a binary row at zero); - `renderReport` counts such rows apart (`N governed merge(s) and M oversized landing(s) with no governed path`), prints a `⛔ SIZE:` row with the numbers and the threshold, keeps the same attribution column, and prints GitHub's own pair beside the landed number only when the two differ (it rides the attribution GET the row already pays for; it never decides the listing); - `--json` entries carry `size` and `humanMerge`. ## The PM's mechanism assumptions, measured - 「the sweep already reads each merged PR (it prints `merged_by`)」 — **falsified.** The attribution loop reads `GET /pulls/{n}` only for rows `classifyCommit` already produced (governed merges); an ungoverned landing is never read at all, so no API read could have made it an entry. Route taken instead: the local `--numstat` reading above, the same source `--branch` already uses in this file, zero API, and it also lets the sweep classify before any attribution is spent. - 「the size reading cannot ride the existing head read for an oversized PR with no governed path; one `GET /pulls/N` per PR in the group」 — **held.** The size leg reads every queued PR through the same reader; the call count is pinned (`apiCalls === carrierPullsInGroup(rows).length`, deduplicated, group order). - 「fail-closed on an unreadable size」 — **taken**, on a code of its own (9) rather than the governed leg's 4, for the same reason 6/7 are split from 3/4: the legs' refusals must stay separable in a log. - 「pick the exit code already highest in the table」 — **taken**: a governed-unsatisfied AND oversized group prints both limbs and exits 3. Consequence worth stating: a merge group naming no pull request now exits on the size leg's 9 rather than the carrier's 7 (both blocks still print their own refusal); pinned. - **Boundary not decided here:** no authorized APPROVED review and no review of record lifts the size limb — the landed predicate says a human MERGE, and nothing has ruled the number the way 2026-08-27 ruled paths. The rendering says so; widening it is a one-line maintainer decision in the sibling. Listed under open questions in the report, not implemented. ## Acceptance (the seat's checklist) - oversized PR, NO governed path, in a merge group → refused, size limb named: the objectstack-ai#18971 replay pin (`⭐ objectstack-ai#18971-replay-an-OVERSIZED-PR-with-NO-governed-path-is-REFUSED-at-the-queue-on-the-size-code`) — governed leg clear at zero reads, size leg exit 8, `groupExitCode` 8; the rendered text names `objectstack-ai#18971`, `238429 changed line(s) (+238310 / -119)`, `EXCEEDS the human-merge line 5000`, `HUMAN MERGE`; - governed path AND oversized → both limbs rendered, one exit (3), pinned; - exactly at the threshold → clear (guard: `⭐ exactly-the-threshold-is-WITHIN-the-comparison-is-strictly-greater`; sweep: `⭐ exactly-the-threshold-is-NOT-listed-the-comparison-is-strictly-greater`, and a real fixture commit of exactly 5,000 lines is not listed); - `pull_request` leg byte-identical: `renderSizeVerdict` returns `''` there and a throwing spy proves zero reads; - unreadable size → exit 9 (throw, missing pair, no recorded reading, group naming no PR), never a pass; - sweep lists an oversized merged PR, not an at-threshold one: unit pins plus a REAL CLI sweep over a fixture repo (`PR objectstack-ai#5001` listed with `⛔ SIZE`, `PR objectstack-ai#5000` not), on stdout and in `--json`; - self-tests green with counts up: guard 261 → 296, governed-merges 410 → 435; battery rosters +1 each, floors 21 → 22 and 29 → 30; - workflow file untouched. ## Verification Self-tests (worktree at `5c7caff` — the branch merged with `origin/main` `c229223`, which touched neither file; after `pnpm install`): - `node scripts/pm/check-governed-queue-guard.mjs --self-test` → exit 0, `296 cases pass` (was `261 cases pass` at `e8667ee`). - `node scripts/pm/check-governed-merges.mjs --self-test` → exit 0, `435 assertions` (was `410 assertions`). Ablation — the comparison inverted in the SIBLING, watched from both files (proves the guard imports the predicate rather than restating it), through `scripts/ablation-replace.mjs` in WRAP mode against the committed head `5c7caff` (the final of three runs; the first two are recorded below because each taught something): - mutation: anchor `exceeds: changedLines > HUMAN_MERGE_LINE_THRESHOLD,` × 1 → the same line with the greater-than sign replaced by a less-than sign, × 1; blob `6e1112bcd55e` → `c7a42160aabd`; on-disk counts read back inside the mutated window: replacement 1, original 0. - guard self-test under mutation: exit 1, `14 of 296 case(s) failed`, all in the objectstack-ai#19036 battery (`threshold-plus-one-changed-line-is-OVERSIZED`, `one-under-is-WITHIN`, `an-OVERSIZED-queued-PR-REFUSES-with-code-8`, `a-within-sibling-does-NOT-carry-an-oversized-PR-through-the-group`, `objectstack-ai#18971-replay-an-OVERSIZED-PR-with-NO-governed-path-is-REFUSED-…`, `governed-AND-oversized-prints-BOTH-limbs-…`, `an-authorized-APPROVAL-…-lifts-NOTHING-from-the-size-…`, `a-certified-pure-regeneration-lifts-…-NOTHING-from-the-size-at-the-queue-either`, …). - governed-merges self-test under mutation: exit 1, `30 failure(s)`, 0 TypeErrors — the pre-existing SIZE battery (`5001-changed-lines-is-OVER-it`, `the-PR-that-prompted-the-ruling-reads-238429-…`, `--test-with-5001-changed-lines-…-exits-3`, …), the new objectstack-ai#19036 battery (`an-oversized-landing-with-NO-governed-path-is-a-sweep-ENTRY-…`, `the-head-counts-the-oversized-landing-APART-…`, `a-REAL-sweep-LISTS-the-over-by-one-landing-…`, …) and two objectstack-ai#13307 live-mirror sweep pins that now see a phantom oversized row (every small landing reads over an inverted line — the expected direction). - restore: blob after restore `6e1112bcd55e…` == `HEAD` blob, `git diff HEAD` empty, `git status --porcelain` empty, anchor count back to 1. - Run 1 (on `3c7f5ec`): guard 14/296 red as above; the merges leg exited 1 by a **TypeError** in my new battery (a pin dereferenced the fixture that classifies to null under the mutation), so its failures were not named — fixed in `28faa51` (null-guarded pins; a red case must be a named one). Run 2: run against that fix while it was still UNCOMMITTED; the tool restores to `HEAD` by design, so the fix was discarded by the restore — caught by the pre/post blob compare (`6e1112…` before, `859965…` after), re-applied, committed, and run 3 is the record above. The "commit the fix first" rule, measured on the fix to the pins. Gates (derived in the worktree with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` — 33 commands, identical to the dispatch's list; reconciled with `--ran`; exit codes captured after redirection, never through a pipe; run at `5c7caff`, the final head, after the ablation's restore was proven): | # | command | exit | verdict line (from the gate's own output) | wall | |---|---|---|---|---| | 1 | `node scripts/check-ci-filter-parity.mjs` | 0 | OK: all 184 declared cross-package glob(s) (131 unique) are covered by `core` or `crosspkg`, every `crosspkg` entry still covers one, and the `test` job's `if:` | 0s | | 2 | `node scripts/check-closing-keyword-parity.mjs` | 0 | check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 8993 tracked | 1s | | 3 | `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | ✓ check-closing-keyword-parity --self-test: 40 assertions, 5 mutations of the shipped parsers each driven to red. | 3s | | 4 | `node scripts/check-comment-mask-corpus.mjs` | 0 | ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 6896 files, 0 disagree, 0 unparseable, 81.9s (comparator self-test: 26 cases pass). | 83s | | 5 | `node scripts/check-declaration-mirrors.mjs` | 0 | OK: 10 hand-written declaration(s) agree with their modules on name, kind and required arity. | 0s | | 6 | `node scripts/check-declaration-mirrors.mjs --self-test` | 0 | All 29 self-test cases passed. | 0s | | 7 | `node scripts/check-scripts-symbol-anchors.mjs` | 0 | ✅ check-scripts-symbol-anchors: 3524 anchors across 265 scripts resolve — 52 symbol (52 declaration, 0 literal), 3472 file-level, 0 cross-repo, 1 exempt, 2 cont | 4s | | 8 | `node scripts/check-scripts-symbol-anchors.mjs --self-test` | 0 | ✅ check-scripts-symbol-anchors --self-test: every finding class provoked, comment-prose projection wired, declined shapes counted not missed, allowance rows exa | 4s | | 9 | `node scripts/check-self-test-wired.mjs` | 0 | ✓ check-self-test-wired: every one of the 214 script(s) CI runs that ship a `--self-test` has that self-test run by CI. | 2s | | 10 | `node scripts/check-self-test-wired.mjs --self-test` | 0 | check-self-test-wired --self-test: 3 live ledger row(s) verified, plus the comment mask, the right boundary, alias resolution and both audit directions — 10 dec | 2s | | 11 | `node scripts/check-self-test-workflow-commands.mjs` | 0 | ✓ check-self-test-workflow-commands: no self-test CI runs prints a line the Actions runner would parse as a workflow command. | 51s | | 12 | `node scripts/check-self-test-workflow-commands.mjs --self-test` | 0 | check-self-test-workflow-commands --self-test: both measured parse rules pinned (legacy form anywhere in a line, current form only at line start), the innocent- | 3s | | 13 | `node scripts/check-skills-token-ratchet.mjs` | 0 | ✓ check-skills-token-ratchet: 34 authored bundle file(s) within their ceilings; 10 generator-owned file(s) measured, not ratcheted. | 0s | | 14 | `node scripts/check-skills-token-ratchet.mjs --self-test` | 0 | ✓ check-skills-token-ratchet self-test: 65 cases pass. | 1s | | 15 | `node scripts/check-whole-set-label-write.mjs` | 0 | ✓ check-whole-set-label-write: 0 violations — 328 file(s) over 3 root(s) · 12 raw mention(s) · 12 in comments/prose (cleared) · 0 in EXECUTABLE content (judged) · 191 `uses:` pin(s) over 18 di | 2s | | 16 | `node scripts/check-whole-set-label-write.mjs --self-test` | 0 | ✓ check-whole-set-label-write --self-test: all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch) | 0s | | 17 | `node scripts/pm/bare-root-worklist.mjs --self-test` | 0 | OK self-test: 81 live row(s), 59 unreachable as spelled, 46 recorded verdict(s) — none stale, none missing, none contradicted (12 row(s) whose gate carries the | 29s | | 18 | `node scripts/pm/check-governed-queue-guard.mjs --self-test` | 0 | ✓ check-governed-queue-guard self-test: 296 cases pass (register-driven verdicts, the queue/PR event split, latest-decisive approval reduction, the 2026-09-04 a | 0s | | 19 | `pnpm check:agent-test-spelling` | 0 | ✓ check-agent-test-spelling: 0 violations — 518 file(s) · 9204 bare `--` token(s) · 1749 launcher-rooted run(s) · 13 separator(s) JUDGED · 6 vitest-backed scrip | 4s | | 20 | `pnpm check:bash32-floor` | 0 | ✓ check-bash32-floor: 31 tracked shell file(s) under scripts/**, .claude/hooks/**, .githooks/** name no bash 4+ construct outside a comment, a guarded ${VAR:-} | 2s | | 21 | `pnpm check:cli-command-ids` | 0 | ✓ check-cli-command-ids: 63 module(s) under packages/cli/src/commands examined, all of them default-export a class whose inheritance chain reaches oclif's `Comm | 11s | | 22 | `pnpm check:closing-target-claim` | 0 | ✓ check-closing-target-claim self-test: 105 cases pass. | 1s | | 23 | `pnpm check:cross-package-test-inputs` | 0 | OK: 29 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob (6 of them on a split "test:repo" task); 13 walked root(s) ju | 20s | | 24 | `pnpm check:driver-memory-census` | 0 | check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live, and every ruled file states "objectstack-ai#6664 census: 2 ruled consumers". This | 4s | | 25 | `pnpm check:entry-guard` | 0 | ✓ check:entry-guard: 265 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 206 export bindings, 206 of them inert on import (0 known-unsafe, ⛔ S | 47s | | 26 | `pnpm check:nul-bytes` | 0 | check-nul-bytes: OK (scanned 8986 text file(s) -- 8986 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes). | 3s | | 27 | `pnpm check:parse-guard` | 0 | ✓ check:parse-guard: 264 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs. | 2s | | 28 | `pnpm check:pm-governed-merges` | 0 | ✓ check-governed-merges --self-test: 435 assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the objectstack-ai#12633 landing wind | 6s | | 29 | `pnpm check:pnpm-filter-targets` | 0 | ✓ check:pnpm-filter-targets: 151/199 `--filter` occurrence(s) across 40 file(s) resolve against 81 workspace package(s); 48 not judged (2 foreign, 26 interpolat | 3s | | 30 | `pnpm check:ratchet-remedy-authority` | 0 | OK check-ratchet-remedy-authority: 259 scripts swept (scripts/*.{mjs,mts} + scripts/pm/*.{mjs,mts}); 15 mark the expanding remedy ⛔ MAINTAINER-ONLY, 5 turn it | 4s | | 31 | `pnpm check:refd-timer-probe` | 0 | OK check-refd-timer-probe: 6891 source file(s) swept; the process-global timer probe is read in packages/qa/refd-timer-testkit/src/index.ts and nowhere else. | 12s | | 32 | `pnpm check:watch-hint-literal` | 0 | ✓ check-watch-hint-literal: 71 declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 47, ROOT_FILE_WATCH_HINTS 13, ROOT_WATCH_HINTS 3, DECLARED_WATCH | 5s | 32 command(s); 0 non-zero exit(s). Reconciliation (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` over a record whose every line carries `:: exit N`): `33 derived, 32 run, 0 NOT-MEASURED, 1 UNRUN` — the one unrun family is `pnpm check:pm-dispatch-gates`, which is **NOT MEASURED at PR-open time**: it runs only under the shared verify lock (≈1,000 s under contention); a first run started on `3c7f5ec` overlapped the ablation window (`dispatch-gates.mjs` imports `sizeVerdict` from the sibling, so a child it spawned in that window could have read the inverted comparison), and a second run on `5c7caff` is queued behind it. Its verdict lands in the `os-dev-report` comment on objectstack-ai#19036, not here — this body is written once. Lint, narrowed and measured: `npx eslint --no-inline-config --format json` on the two changed files → exit 0, 2 files, 0 errors, 0 warnings. Narrowing evidence: ① eslint's configured population (`eslint.config.mjs`, the `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block) includes `scripts/pm/*.mjs`, and `--print-config` resolves a config for the file (parser `typescript-eslint/parser`); ② the JSON output counts 2 files; ③ type-aware linting is not enabled (`parserOptions.project` and `projectService` both absent in the resolved config), so this diff cannot move any untouched file's verdict. The repo-level `pnpm lint` sweep is CI's run. ## Acceptance notes (noted, not filed) - The `pull_request` event payload carries `additions` / `deletions`, so a size early warning on the PR leg would cost zero reads. Not taken: the ruling is about the landing, the seat-side pre-check already refuses before arming, and the PR leg's byte-identity is a standing constraint. 承接者: the skills seat, if the maintainer wants the forecast. - The carrier leg and the size leg each read `GET /pulls/{n}` once per queued PR — the same endpoint twice. A shared per-run pull read would halve it; kept separate so each leg's refusal and count stay separable in a log. Groups are small. 承接者: none. - No governed reference text enumerates the guard's exit table, so codes 8/9 leave no Tier S doc stale (`grep` over `.claude/skills/pm-dispatch/references/**` and `SKILL.md` for `exit 6`/`exit 7`/`EXIT_REFUSED_CARRIER`: 0 hits). `skip-changeset`: `scripts/pm/**` publishes nothing from any released package (no `files[]` of any package ships it). --- _Generated by [Claude Code](https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #16045
Clause-②: yes (widening)
Ruled at
5560224701(director batch #60, 2026-09-06, maintainer verbatim 「同意」), re-affirmed by triage at5724532096: 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/specpinned its public surface on one axis.api-surface/records each export asname (kind), and a signature change, a renamed interface field and a dropped union member move none of those rows. The only shape pin wasapi-surface-signatures.json: 27 rows, and reference-level even there. This addsapi-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.
b33898f5d)exportsmap — those whoserequire.typesends in.d.ts. Adding or removing one such subpath.exportsmap entries./openapi.jsonand./package.json— asset subpaths with no declaration at all, filtered out by the same.d.tstestbuild-api-surface.tshas always applied. ⇒ premise 1 resolved: 17 is right and the map did not grow; 19 counts two things that were never entry points.name (kind)rows summed over the 17api-surface/shards. +27 since the card. Ratio unmoved: 27/5336 = 0.51%, so the headline 99.5% stands.api-surface-signatures.json. Bright control: the first value really is asha256:string, so this counts signature entries and not empty objects.Premise 3 also holds: all 17 packed
.d.tsfiles 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
dist, so about +5.8% of tarballsystem.txt, 3,592,701 bytes / 73,283 linesEnvironmentArtifactSchema21,868,ObjectStackDefinitionSchemaandObjectStackSchema21,851,ChangeSetSchema20,395)Stated plainly, as the dispatch asks, and ⛔ not as a veto: the packed
.d.tsis 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 todegradationReason?inpackages/spec/src/contracts/job-service.ts(blob363443e2tod4b1520c), spec rebuilt,ablation-dist-preflightexit 0 confirming the marker reached the built artefact:Restore leg: blob back to
363443e2, rebuilt,ablation-dist-preflight --absentexit 0 (marker gone from all 214 built files),git diff HEADclean, gate back to exit 0.B. the gate can fail on its own artefact. One field renamed inside
qa.txtby hand (blob3f5efb04to5b86fec2, injected occurrences 1, deleted text 0): exit 1, attributed toTestSuiteSchema (const), failure text naming the regenerate command. Restored to the HEAD blob,git diff HEADempty: 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 fromdefineActionthroughdefineWebhook, 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 astypeToStringwas. What is gained is not sharper text on the 27; it is the 5309 other declarations, includingActionSchemaitself, 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'sfiles[].Where it lands, and why there
packages/spec/scripts/build-api-surface-declarations.ts, beside the eight sibling artefact generators, reading the same input through the samecollectEntrieslogic. The ruling says "one generator script underscripts/"; 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.packages/spec/api-surface-declarations/ENTRY.txt, a sibling directory ofapi-surface/. Not inside it:listShardNamesthrows on any file in that directory that is not aNAME.jsonshard, soapi-surface/is closed by construction. No existingapi-surface/*.jsonis regenerated by this PR (check:api-surfacegreen throughout), which keeps it clear of PR feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node #18688 and PR feat(spec)!: manifest.id enforces the reverse-domain rule its registry face already had #18319.check:api-surface-declarations, a step in lint.yml'sType Check · consumer gateslane after the two build steps, withcheck:api-surfaceand the other dist-reading gates. No new required context — a step in an existing lane. Registered in thecheck:generatedledger, inREGEN_ARTIFACTS, and in.gitattributesasmerge=os-regen.5715457322is answered by the layout rather than by an assumption — andcheck:merge-driver, which reconciles.gitattributesagainstREGEN_ARTIFACTSin both directions, is green over the swap.check:published-filesrefuses afiles[]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 questionapi-surfacecannot answer. If 1.02 MiB of tarball is judged too much, one line offiles[]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 asCLASS_EIGHTH, with a case asserting the edge really reaches it.scripts/pm/check-widening-tells.mjs—PUBLISHED_SURFACESis derived fromREGEN_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
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsfrom 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.@objectstack/speclocal 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 typecheckgreen.eslint . --no-inline-config --format jsonatb33898f5dexamined 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 ineslint.config.mjs(noparserOptions.project, no typed rules), so this diff cannot move an untouched file's verdict either way.check:nul-bytesgreen 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.mjsin the diffstat is not mine: it arrived with the one-commitorigin/mainmerge (16cb493d5) this PR carries.Acceptance notes
.claude/skills/spec-property-retirement/SKILL.mdline 124 listsapi-surface-signaturesas 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.tsline 830 carries the same stale mention. Left untouched because PR feat(spec)!: publish the two named refinement patterns the runtime already enforces #18952 holds that file; noted, not filed, with the later lander as the natural carrier.scripts/pm/check-widening-tells.mjs(PR fix(pm): check-widening-tells reports a key re-typed INTO a universal acceptor #18948),scripts/pm/dispatch-gates.mjs(PR skills(pm-dispatch): key the clause-② contract review by lane — spec and skills owe it on every round, other lanes owe none #18903) and.github/workflows/lint.yml(PRs chore(gates): retire check-type-source-resolution (maintainer ruling E on #18373) #18946, feat(spec): ship a per-release section in spec-changes.json, verified against both tarballs #18889, feat(scripts): refuse an undeclared mode-160000 gitlink in the index #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.维护者速读(草稿)
改了什么。
@objectstack/spec从今天起为它的每一个公开导出留一份"形状快照" —— 不是哈希,而是打包后.d.ts里那段声明原文,按入口点分成 17 个文件签入仓库,并配一道 CI 闸门:重新生成后对不上就红,失败信息里直接给出重新生成的命令。同时退休了旧的 27 条签名哈希文件。为什么改。 原来的 pin 只记"某个名字还在不在",5336 行里只有 27 行能看出"形状变没变"。也就是说:把一个接口字段改名、砍掉一个联合成员、改一个函数签名 —— 这些都是会让客户升级后编译失败的破坏性改动 —— 全部一路绿灯。本次 PR 里有实测:改了
JobRunOutcome的一个字段名之后,旧闸门check:api-surface退出码 0(看不见),新闸门退出码 1(点名了那个 interface)。路线是 2026-09-06 决策批次 #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