Skip to content

chore(gates): retire check-type-source-resolution (maintainer ruling E on #18373) - #18946

Merged
os-try-charles merged 3 commits into
mainfrom
claude/issue-18373-retire-type-source-resolution-gate
Sep 18, 2026
Merged

os-try-charles merged 3 commits into
mainfrom
claude/issue-18373-retire-type-source-resolution-gate

Conversation

@os-try-charles

@os-try-charles os-try-charles commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #18373

Clause-②: no

skip-changeset — measured, not asserted; see Verification below.

Executes the maintainer ruling of 2026-09-18 on this card (batch #153 item 3, letter E):
retire check-type-source-resolution. This PR is EXECUTION — it does not re-argue A/B/C/D.

What the ruling ordered, and where each piece landed

the ruling's words landed as
delete scripts/check-type-source-resolution.mjs and its self-test file deleted. Its "self-test" was the file's OWN --self-test dispatch, not a separate file, so it went with it (git ls-files matched exactly one path for the name).
KNOWN_DIST_RESOLVED_TYPE_IMPORTS with it that registry lived inside the deleted file; the identifier now has 0 occurrences anywhere in the tree.
the check:type-source-resolution entry in the root package.json removed (one line).
the 「Type-source resolution gate」 step in .github/workflows/lint.yml step removed, together with the 23-line comment block that exists only to explain it.
AGENTS.md and any doc that names the gate as a standing check AGENTS.md names it zero times — re-measured here, see §1. This diff does not touch the governed surface.
docs/audits/gate-census-2026-09.md:215 verdict rewritten to the ruling's exact text, see §2.
PR #18708 not touched.
check:test-source-alias (the vitest-axis sibling) not touched, and its ledger is not touched.

1. AGENTS.md: the ruling's sentence describes a sentence that does not exist

Re-measured independently of the dispatch, whitespace-flattened first so wrapped prose
cannot give a false zero, every zero paired with a control drawn from the same flattened
population:

type-source-resolution        0        check:test-source-alias   1   (control, hits)
Type-source resolution        0        check:                   47   (control, hits)
type source resolution        0        test-source-alias         1   (control, hits)
TYPE SOURCE RESOLUTION        0
KNOWN_DIST_RESOLVED           0
check-type-source-resolution  0        file 78,857 bytes / flattened 76,881 bytes

My reading agrees with the dispatch's. So the ruling's AGENTS.md clause has no
referent, no line was hunted for there, and AGENTS.md / CLAUDE.md / .claude/** /
skills/** / docs/adr/** are all absent from this diff.

2. The two censuses — deliberately different acts

docs/audits/gate-census-2026-09.md — verdict REWRITTEN. Its verdict column is a
forward-looking disposition (what should happen to the gate), which is exactly what a
later ruling can override. The row's verdict now reads
retire · maintainer ruling 2026-09-18 on #18373, the ruling's own spelling.
Because that is a NEW verdict spelling, the document's own verdict-count table was kept
arithmetically true in the same edit: keep 133 to 132, a new row for the new spelling at
1, retire (all spellings) 59 to 60, keep (all spellings) 150 to 149. The union still
sums to 225 rows. Nothing else on the row moved — class, contract, blast radius and the
measured catch window are what the census measured, and this PR did not re-measure them.

docs/audits/2026-09-self-test-shape-census.md:341 — deliberately LEFT ALONE. The
dispatch flagged it as a second carrier the ruling did not name; it holds a
ROSTER | HELD row for this gate. It gets nothing, for a reason, not by omission:

Rule applied, and the same rule decides every prose carrier below: a sentence that makes
a present-tense claim about the gate acting is now false and is repaired; a sentence
recording a past measurement or why a past change happened is not.

3. The hard coupling: check:ratchet-remedy-authority, measured before and after

That gate keeps a hand-classified control corpus keyed on gate FILENAME, and its self-test
asserts the sweep reaches every entry. Both legs, run from this worktree:

leg --self-test main run
before any change (at 02bdeaaf2) exit 0 exit 0 — 257 scripts swept, 15 marked, 6 refused, control corpus 31
after deleting the file only exit 1 — the sweep still REACHES every known instance; it no longer reaches: check-type-source-resolution.mjs exit 1 — STALE: the control corpus ... covers scripts/check-type-source-resolution.mjs, which is no longer in the corpus. Drop the entry, or restore the file.
after the repair in this PR exit 0 exit 0 — 256 scripts swept, 15 marked, 5 refused, control corpus 30

The repair is the gate's own prescribed remedy, and no floor moved. What that gate pins
is SELF_TEST_BATTERIES — a roster of battery NAMES with a per-battery count floor and a
pinned roster SIZE, and its own comment at the roster says deleting an entry silences a
floor as effectively as zeroing it. That roster is a different registry from the
control corpus, and it is untouched in substance:

declared batteries: 21      SELF_TEST_BATTERY_FLOOR: 21      sum of counts: 30
battery (12) count: 1   (unchanged)

The control corpus (CONTROL) has no pinned size — the gate prints Object.keys(CONTROL).length
— and its STALE branch names dropping the entry as the fix. 257 to 256 swept, 6 to 5 refused
and 31 to 30 classified are the mechanical consequence of one file leaving the corpus, not a
weakened floor.

Three further carriers in that same file, each judged by the rule in §2:

  • :16 "The precedents are ..." — present tense, names four files a reader is told to open.
    The dead name is dropped; the other three stay.
  • :1221 the author-facing remedy "turn it down outright the way check-type-source-resolution.mjs does" —
    present tense, and after this PR it points an author at a file that does not exist. The
    exemplar is swapped to check-test-source-alias.mjs, the co-precedent of the identical
    PREDICATION shape that this same file already names at :16 and in battery (12).
    ⛔ This names that gate; it does not touch it or its ledger.
  • battery (12)'s label and its assertion text ("the shape the two registry gates use") —
    present tense, now one gate. Label renamed, assertion reworded. Roster size and the
    battery's own count are unchanged, so nothing is unpinned.
  • :662 "…which turned check-type-source-resolution's CORRECT remedy into a reported
    violation" — a record of a measurement that was taken and rejected. Historical: kept.

4. The coupling the dispatch did not name: check-type-check-coverage.mjs

Found by re-measuring rather than by the brief. That gate's live, author-facing TEST_DEBT
graduation remedy told an author route (b) was "Available ONLY while
pnpm check:type-source-resolution still passes with the tests re-admitted ... Run it before
you commit". After this PR that is a command that does not exist, in a message whose whole
job is to tell an author which of two routes is open.

Repaired so it keeps the WARNING and loses the dead instruction: it now records that the
gate that decided the route was retired under this ruling, that its silence is ⛔ not a
clearance, that what it measured has not changed (the re-admitted tests import workspace
packages the src program never held; it read red on 14 of the 18 entries with an exclusion
to drop), and that (a) is the route to prefer.

⛔ The self-test that pins that message is NOT weakened. Its present needles
(check:type-source-resolution, SHRINK-ONLY, tsconfig.test.json) and the sibling
FUTURE_DEBT case's absent needles are left byte-identical — the rewritten message
still carries all three, because it names the retired gate and its former registry
explicitly. Only the case LABEL and its explanatory why changed. pnpm check:type-check-coverage
exits 0 after the edit.

The other five mentions in that file (:934, :986, :1099, :4462, and the :537 /
:5629 provenance notes) are records of measurements — "MEASURED as a red main", "SINCE
MEASURED ... by dropping each entry's exclusion and reading check:type-source-resolution",
"measured by doing it". Under the §2 rule the measurements are kept; the two that also made
a present-tense claim about a live consumer (:537, :5629) now say the gate was retired.

5. The other repo-root tooling carriers

  • scripts/typecheck-configs.mjs — this library existed because two gates needed the same
    predicate. One is gone. Its self-test does not assert a consumer set (checked: no
    consumer array, only prose), so nothing reds; but "Two gates need this predicate", "both
    consumers resolve", "the two callers" and ":201 check-type-source-resolution.mjs imports
    the predicates" were all present-tense and false. Repaired to name the one live consumer and
    record the retirement. ⛔ Folding the module back into its remaining caller is explicitly
    left as a separate decision — its cases are floored in its own dispatch (PR test(scripts): batch 10a — class-3 floors at the verdict site for four self-tests #15327) and a
    fold-in would not inherit that floor.
  • scripts/check-undeclared-dep-imports.mjs:42 — "the two gates that look adjacent" is now one.
  • scripts/workspace-enumerator.mjs:66 — the WORKSPACE_PARENT_GLOBS declaration list named
    the deleted file; it now names the live one and records where the other went.
  • scripts/pm/dispatch-gates.mjs — five mentions, all left alone: every one is a recorded
    measurement in a docblock (pair counts of a matcher variant that was measured and refused).
    Historical under the §2 rule. The tool derives its families from package.json and the
    workflows at run time, so the retired gate simply leaves its output; it needs no edit and
    reds nothing.
  • scripts/pm/check-clause2-carriers.mjs:8209 / :8250 — verified offline before deciding,
    and left alone.
    The record is a frozen inline array of comment bodies passed
    headSha: 'offline'; nothing in it resolves a remote ref, and the two mentions are branch
    names inside a historical claim-contest fixture about this card, unrelated to the gate.
  • scripts/typecheck-configs.mjs:202's stale comment about its importer — see above; that is
    the comment the dispatch flagged as pointing the other way.

6. Out of scope, on purpose

  • ⛔ packages/*/CHANGELOG.md (five files) — untouched. AGENTS.md:686 is unconditional:
    a factual error in a released entry is repaired in a dedicated docs-only PR, ⛔ never as a
    rider on code changes. They are also correct as historical records of what those releases did.
    Same for content/docs/releases/** (which names it zero times anyway).
  • About 30 prose carriers in packages/**/tsconfig*.json comments, test docblocks,
    packages/cli/bin/run-dev.js and examples/*/tsconfig.json — untouched, and reported to the
    PM as a residual.
    Boundary applied: the ruling scoped this diff itself when it moved the lane
    to domain:devx 「the diff is repo-root tooling, package.json and the lint workflow」.
    Editing those comments would pull roughly 18 packages and 3 examples into the changeset,
    change the diff's lane, and multiply the derived gate set — for comments that mostly explain
    why a paths rule exists, a reason that outlives the gate.

7. The workflow step removal leaves the required context intact

Measured, not asserted. The required context is the JOB's name:, and no context name is
derived from a step:

BASE 02bdeaaf2 : lint job `name:` = Lint & Repo Gates   steps = 179   (step present)
HEAD           : lint job `name:` = Lint & Repo Gates   steps = 178   (step absent)

The job keeps 178 other steps and its name is byte-identical, so the six required contexts
are unchanged. pnpm check:required-contexts exits 0. ⚠️ One wording note for the record:
the ruling writes the job as 「Lint and Repo Gates」; the job's actual name: is
Lint & Repo Gates (ampersand). Same job, and the ruling's conclusion holds.

8. skip-changeset, measured

Criterion: nothing already published moves. Measured against every workspace manifest's
files[], with a positive control proving the reader works rather than merely reporting zeros.

changed paths (9)                              files[] reaches
  .github/workflows/lint.yml                     NONE
  docs/audits/gate-census-2026-09.md             NONE
  package.json                    (private:true) NONE
  scripts/check-ratchet-remedy-authority.mjs     NONE
  scripts/check-type-check-coverage.mjs          NONE
  scripts/check-type-source-resolution.mjs       NONE   (deleted)
  scripts/check-undeclared-dep-imports.mjs       NONE
  scripts/typecheck-configs.mjs                  NONE
  scripts/workspace-enumerator.mjs               NONE

POSITIVE CONTROL — must be reported as reached
  packages/spec/src/data/query.zod.ts            @objectstack/spec   (glob entry)
  packages/cli/dist/index.js                     @objectstack/cli    (directory entry)
  packages/spec/CHANGELOG.md                     @objectstack/spec   (literal entry)

3 of 3 controls hit, across two packages and all three files[] entry kinds, so the zeros
above are readings and not an empty read. The root package.json is private: true and is
never published at all.

9. Verification

Gate set derived in-worktree with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths
passed — the tool takes its own change set from the merge base), then run. Union taken at
13b2b68ef, the final commit.

  • 69 of 69 derived commands run. 63 exit 0.
  • 6 exit 3 = PREREQUISITE NOT MET, NOT MEASURED, declared to CI: check:dts-closure,
    check:dual-build-cjs-loads, check:lean-entry-closure, check:sourcemap-no-sources-content,
    check:type-check-debt and @objectstack/lint check:doc-formula-expressions. Every one of
    them refuses because this worktree has no build; each prints its own "this is NOT a pass"
    line and exits 3 rather than 1. They are derived from the root package.json edit, and they
    read the dist/ of packages this diff does not touch — this diff changes no package source,
    so their verdict cannot depend on it. CI's build lanes measure them.
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) — exit 0.
  • pnpm check:ratchet-remedy-authority — exit 0, before/after table in §3.
  • pnpm check:type-check-coverage — exit 0.
  • pnpm check:pm-dispatch-gates — exit 0, dispatch-gates self-test: 1848 cases pass
    (976.3s on this box; run on its own because it does not fit a ten-minute foreground window).
  • pnpm check:required-contexts, check:step-collectors, check:self-test-wired,
    check:self-test-workflow-commands, check:aggregator-roster, check:scripts-symbol-anchors,
    check:declaration-mirrors, check:ci-filter-parity, check:nul-bytes,
    check:workflow-step-name-quoting — all exit 0.
  • Control-byte self-scan over every changed file
    (grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') — clean.
  • ⚠️ dispatch-gates prints its own warning that --commands is not a complete account
    of CI; the artifact-roster, wide-population, workflow-valued and path-scheduled CI families
    are outside that list by construction.
  • ⚠️ dispatch-gates also reported a STALE TREE note: origin/main moved 3 commits after this
    branch point and scripts/pm/check-widening-tells.mjs changed there. The derivation itself
    uses three-dot merge-base semantics, so the change set above is correct; the note affects only
    that one family's shape. No path of this diff overlaps it.

10. Serial constraint with two in-flight PRs

Both #18889 (draft) and #18414 (open, non-draft) also edit .github/workflows/lint.yml
and the root package.json. Re-checked immediately before pushing: both are still open and
unmerged
, so neither had landed under this branch. This diff is written to survive either
landing first — ⛔ no line number was used as a reading:

  • the workflow step is located by its own name, and the edit asserted the literal text of
    - name: Type-source resolution gate, its run: line and the first line of its comment block
    before removing anything; a shifted file fails the assertion instead of deleting the wrong step.
  • the package.json entry is matched as a unique exact string, never by offset.

The ruling's .github/workflows/lint.yml:4187 and the dispatch's package.json:172 were both
treated as clues; both happened to still be correct at 02bdeaaf2, but nothing here depends on that.

Also re-measured against a freshly fetched origin/main (46559f61c, five commits past this
branch point): none of those five commits touches any of this diff's nine paths, so no merge
was needed and no line re-derivation was owed.

#18708 is closed, unmerged (2026-09-18T06:37Z) — confirmed here, not assumed. This PR does not
touch it. #18903 is editing scripts/pm/check-clause2-carriers.mjs, the file holding the
frozen #18708 fixture this PR deliberately leaves alone — adjacent, not overlapping.

Acceptance notes

  • Noted, not filed: the gate census's inventory counts (182 check files, 225 rows) are pinned
    to the census's own tree and were deliberately not re-derived — only the verdict column and
    its roll-up were touched. Carrier for a future re-derivation: whoever executes the next
    batch of the 58 remaining retire rows.
  • Noted, not filed: docs/audits/gate-census-2026-09.md now carries a verdict spelling
    (retire · maintainer ruling ...) that no other row uses, where the existing convention for a
    ruling-driven retirement is the verdict retire (ruled) plus ruled retire: #NNNNN X in
    column 3. The ruling's literal text was followed rather than the convention.

Generated by Claude Code


Generated by Claude Code

…step, ratchet-remedy coupling)

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…ed it as standing

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
@os-try-charles os-try-charles added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 18, 2026 — with Claude
@github-actions github-actions Bot added size/xl ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation labels Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

站开说明:Validate Package Dependencies 这条红,不是本 PR 的

domain:devx 执行席(座位贴 #6023)。本 PR 只删门禁与相关散文,九个改动路径里没有 pnpm-lock.yaml(已核:diff 里含 lock 的文件数为 0)。

判据:三个都碰根 package.json 的 PR,同一条检查

PR head 跑于 Validate Package Dependencies
#18414 2026-09-16T09:55Z success
#18889(另一位作者,另一片代码) 2026-09-18T06:04Z failure
本 PR #18946 2026-09-18T07:23Z failure

⇒ 两个互不相干的 PR 在同一天同时红,而较早的那次 head 是绿的。这条检查是按路径触发的(近期已合的 PR 上它整条 absent,因为它们不碰 package.json),所以它不会在那些合入里留下痕迹——⛔ 「最近都合进去了」在这里不是反证。

⭐ 更强的读数不在本 PR 上:卡 #18930 已经用一条亮对照把它钉在 main 上 —— main 的定时跑 35301766597 失败,而前一天的定时跑 35176747270 成功。⇒ 公告是在那 24 小时里到的。⛔ 不是永久坏掉的 job,⛔ 不是基础设施抖动。

失败内容本身与本 PR 的改动无任何交集:

| https://osv.dev/GHSA-9rgm-9g3h-6x36 | 5.3 | npm | devalue | 5.9.0 | 5.9.2 | pnpm-lock.yaml |

⛔ 修法存在,但本席不把它搬进这个 PR,理由写在这里供复核

修法是 PR #18942(os-litant,卡 #18930 p1,已认领已交付):pnpm-workspace.yaml 里把 devalue 钉到 ^5.9.2,外加 pnpm install 重算锁文件。本席不移植,三条理由:

  1. ⭐ 移植的安全性前提在这一类改动上不成立。 「先移植,等基线带上它就自动空转」之所以安全,是因为同一处改动会归一。锁文件不会 —— 两次独立的 pnpm install 产出互相冲突的 pnpm-lock.yaml,移植保证造出一处冲突,而不是一个空转。
  2. 修法已经有主、已经做完,就在 fix(deps): pin devalue onto the patched 5.9.2 line (GHSA-9rgm-9g3h-6x36) #18942 里等着落地。再做一遍是重复劳动加一处必然冲突。
  3. 那是一处依赖变更,塞进一个「退役门禁」的 PR 就是本席不该做的扩面。

⇒ 本 PR 在此待命:等 #18942 落地后把基线并进来重跑,⛔ 不重试装弹,⛔ 不用任何削弱门禁的办法买绿。重跑对一条真实公告无效,所以那一次「可用的重跑」本席也不花。

其余检查仍在跑;它们的判定另行读。

读数时刻 2026-09-18T07:32Z


Generated by Claude Code

@os-try-charles
os-try-charles marked this pull request as ready for review September 18, 2026 08:40
@os-try-charles
os-try-charles added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 03b7b81 Sep 18, 2026
37 checks passed
@os-try-charles
os-try-charles deleted the claude/issue-18373-retire-type-source-resolution-gate branch September 18, 2026 09:04
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e the 27 signature hashes (objectstack-ai#18971)

Fixes objectstack-ai#16045

Clause-②: yes (widening)

Ruled at `5560224701` (director batch objectstack-ai#60, 2026-09-06, maintainer
verbatim 「同意」), re-affirmed by triage at `5724532096`: option A, a
readable declaration-text snapshot, ⛔ not a hash. The card body's three
mutually exclusive routes predate that ruling and were not re-litigated
here.

`@objectstack/spec` pinned its public surface on one axis.
`api-surface/` records each export as `name (kind)`, and a signature
change, a renamed interface field and a dropped union member move
**none** of those rows. The only shape pin was
`api-surface-signatures.json`: 27 rows, and reference-level even there.
This adds `api-surface-declarations/`, the declaration text the packed
build actually emits for every export of every published entry point,
and retires the 27 hashes it subsumes.

## The counts, re-derived on this head before the first generation

The ruling asks for this by name; the card's own numbers were
self-declared unverified and 12 days old.

| Number | Card | This head (`b33898f5d`) | Unit, and what would make it
something else |
|---|---|---|---|
| entry points | 17 | **17** | type entry points in the `exports` map —
those whose `require.types` ends in `.d.ts`. Adding or removing one such
subpath. |
| `exports` map entries | (not stated) | **19** | every key in the map.
The extra two are `./openapi.json` and `./package.json` — asset subpaths
with no declaration at all, filtered out by the same `.d.ts` test
`build-api-surface.ts` has always applied. ⇒ premise 1 resolved: **17 is
right and the map did not grow**; 19 counts two things that were never
entry points. |
| pinned rows | 5309 | **5336** | `name (kind)` rows summed over the 17
`api-surface/` shards. +27 since the card. Ratio unmoved: 27/5336 =
0.51%, so the headline 99.5% stands. |
| distinct exported names | (not stated) | **5200** | (entry, name)
pairs. The gap to 5336 is dual-declared names, which are two rows by
design. |
| signature hashes | 27 | **27** | top-level keys of
`api-surface-signatures.json`. Bright control: the first value really is
a `sha256:` string, so this counts signature entries and not empty
objects. |

Premise 3 also holds: all 17 packed `.d.ts` files exist and resolve
through the map (3,215,437 bytes for the root entry down to 13,081 for
`./integration`). No entry point lacks a packed declaration, so the gap
the dispatch reserved for itself did not open.

## What the artefact costs — premise 4, which nobody had costed

| | |
|---|---|
| shards | 17, one per entry point |
| declaration blocks | 5336 |
| bytes | **12,661,943 (12.08 MiB)** |
| lines | **237,706** |
| gzipped | **1,071,825 (1.02 MiB)** — against this package's ~17.57 MiB
compressed `dist`, so about **+5.8%** of tarball |
| largest shard | `system.txt`, 3,592,701 bytes / 73,283 lines |
| median declaration | **81 bytes** |
| skew | the 20 largest declarations hold **~65%** of all bytes; four
exceed 20,000 lines each (`EnvironmentArtifactSchema` 21,868,
`ObjectStackDefinitionSchema` and `ObjectStackSchema` 21,851,
`ChangeSetSchema` 20,395) |

Stated plainly, as the dispatch asks, and ⛔ not as a veto: the packed
`.d.ts` is a tsup dts rollup, so a Zod schema's declaration is its
**fully expanded** structural type. That expansion is exactly what makes
an inner field rename visible — and it is also why a single schema can
produce a 21,000-line diff. The ruling's stated reason for choosing text
over a hash is that the contract-review seat reads the diff; that
reasoning holds per declaration and is worth a second look at the top
twenty. One reading, for whoever wants it: 31% of declarations hold
97.7% of the bytes, so nothing cheap is available by trimming the tail.

## Both instruments, measured on one tree at one commit

The card's thesis is that the old pin cannot fail on a shape change. Not
argued — ablated, with the mutation proven on disk by blob hash and the
mutation proven to have reached `dist/` before any verdict was read.

**A. the source-level control — a renamed interface field, the card's
own class.** `JobRunOutcome.reason?` renamed to `degradationReason?` in
`packages/spec/src/contracts/job-service.ts` (blob `363443e2` to
`d4b1520c`), spec rebuilt, `ablation-dist-preflight` exit 0 confirming
the marker reached the built artefact:

```
check:api-surface              exit=0    "public API surface unchanged"      [BLIND]
check:api-surface-declarations exit=1    "~ JobRunOutcome (interface)"       [SEES IT]
```

Restore leg: blob back to `363443e2`, rebuilt, `ablation-dist-preflight
--absent` exit 0 (marker gone from all 214 built files), `git diff HEAD`
clean, gate back to exit 0.

**B. the gate can fail on its own artefact.** One field renamed inside
`qa.txt` by hand (blob `3f5efb04` to `5b86fec2`, injected occurrences 1,
deleted text 0): exit **1**, attributed to `TestSuiteSchema (const)`,
failure text naming the regenerate command. Restored to the HEAD blob,
`git diff HEAD` empty: exit **0**.

## The retirement, and the coverage proof the ruling demands

All **27** signature names resolve to a declaration block in
`api-surface-declarations/root.txt`, **0 missing** — enumerated from
`defineAction` through `defineWebhook`, each as `(function)`.

One honest qualification, because the subsumption is not uniform. For
those 27 factory declarations the text is `declare function
defineAction(config: z.input of ActionSchema): ActionParsed;` — a type
**reference**, exactly as blind to an inner-key narrowing as
`typeToString` was. What is gained is not sharper text on the 27; it is
the **5309 other declarations**, including `ActionSchema` itself, whose
own expanded block is where such a narrowing shows up. So the retirement
is a strict superset of pinned declarations, not an equal trade. Nothing
published read the retired file — it was never in this package's
`files[]`.

## Where it lands, and why there

- Generator: `packages/spec/scripts/build-api-surface-declarations.ts`,
beside the eight sibling artefact generators, reading the same input
through the same `collectEntries` logic. The ruling says "one generator
script under `scripts/`"; this reads that as the directory the whole
family lives in, because the artefact reads the **built dist** and only
the lane that builds spec can run its gate.
- Artefact: `packages/spec/api-surface-declarations/ENTRY.txt`, a
sibling **directory** of `api-surface/`. Not inside it: `listShardNames`
throws on any file in that directory that is not a `NAME.json` shard, so
`api-surface/` is closed by construction. No existing
`api-surface/*.json` is regenerated by this PR (`check:api-surface`
green throughout), which keeps it clear of PR objectstack-ai#18688 and PR objectstack-ai#18319.
- Gate: `check:api-surface-declarations`, a step in lint.yml's `Type
Check · consumer gates` lane after the two build steps, with
`check:api-surface` and the other dist-reading gates. **No new required
context** — a step in an existing lane. Registered in the
`check:generated` ledger, in `REGEN_ARTIFACTS`, and in `.gitattributes`
as `merge=os-regen`.
- Sharded per entry point from day one, for the reason its neighbour is:
the merge queue rebuilds server-side where no custom driver runs, so two
PRs sharing one generated file evict the second. Pit 1 from `5715457322`
is answered by the layout rather than by an assumption — and
`check:merge-driver`, which reconciles `.gitattributes` against
`REGEN_ARTIFACTS` in both directions, is green over the swap.
- Published, with the reason the gate demands. `check:published-files`
refuses a `files[]` entry that carries none; the registered line says
what a consumer does with it — read two published tarballs and see
*which declared shape* moved between releases, the question
`api-surface` cannot answer. If 1.02 MiB of tarball is judged too much,
one line of `files[]` removes it without touching anything else.

Three registries had to learn about the new gate, each because it
discovered the gate on its own rather than because a list named it:

- `check:published-files` — demanded the reason above.
- `scripts/pm/dispatch-gates.mjs` — its live manifest edge gave the new
gate a population before anything listed it, which is the eighth member
of a class whose seventh was recorded the same way. Declared as
`CLASS_EIGHTH`, with a case asserting the edge really reaches it.
- `scripts/pm/check-widening-tells.mjs` — `PUBLISHED_SURFACES` is
derived from `REGEN_ARTIFACTS`, so retiring the signatures row dropped
it off that surface and reddened two self-test cases. Both are
retargeted to state the retirement as a counterfactual (the surface
follows the table, not a literal); ⛔ the new artefact is **not** added
to that surface, because the ruling assigns "is a snapshot diff a
Clause-② signal" to the skills seat by name and out of this card's
scope. Both directions are now pinned, so the boundary is declared
rather than forgotten. 483 cases pass, up from 481.

## Verification

- **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs
--repo objectstack-ai/objectstack --commands` from the merge base, 120
commands, every exit code redirected to a file and read back. **All 120
green.** Four returned exit **3** PREREQUISITE NOT MET on first pass
(`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt`); each names a
build, each was built and re-run green, and none is recorded as a
finding. Reconciled with `--ran`.
- **Tests**: `@objectstack/spec` local project **488 files / 14,182
tests passed**; the tooling suites that name the edited scripts, both
projects, **10 files / 220 tests passed** (`sharded-artifacts`,
`check-generated-ledger`, `dist-freshness`, `dist-freshness-adoption`,
`api-surface-dual-kind-rows.pin`, `build-schemas-check-mode`,
`def-key-collisions`, `root-index`, `export-list`,
`docs-import-surface`). `pnpm --filter @objectstack/spec typecheck`
green.
- **eslint, the union rather than a narrowing**: `eslint .
--no-inline-config --format json` at `b33898f5d` examined **6856
files**, **0 errors, 0 warnings**, exit 0. The population is eslint's
own config resolution and the count is read from its JSON output;
type-aware linting is not enabled in `eslint.config.mjs` (no
`parserOptions.project`, no typed rules), so this diff cannot move an
untouched file's verdict either way.
- **Control bytes**: `check:nul-bytes` green over 8906 files, plus a
direct scan of all 31 changed paths for the wider control-byte class —
no matches.
- `scripts/check-single-claim-paths.mjs` in the diffstat is **not
mine**: it arrived with the one-commit `origin/main` merge (`16cb493d5`)
this PR carries.

## Acceptance notes

- `.claude/skills/spec-property-retirement/SKILL.md` line 124 lists
`api-surface-signatures` as an instance of a retirement shape, and that
row goes stale with this landing. ⛔ Left untouched on purpose:
`.claude/**` is a governed surface, so editing it would make this whole
PR maintainer-landed for a one-word prose nit. Noted, not filed.
- `packages/spec/scripts/build-schemas.ts` line 830 carries the same
stale mention. Left untouched because PR objectstack-ai#18952 holds that file; noted,
not filed, with the later lander as the natural carrier.
- Three files in this diff are held by open PRs and were edited anyway
because the retirement forces it, not by choice:
`scripts/pm/check-widening-tells.mjs` (PR objectstack-ai#18948),
`scripts/pm/dispatch-gates.mjs` (PR objectstack-ai#18903) and
`.github/workflows/lint.yml` (PRs objectstack-ai#18946, objectstack-ai#18889, objectstack-ai#18414). All are
hand-written files where a text conflict is visible rather than silent,
and all three of my hunks are small and far from theirs. Whoever lands
second resolves.
- The top-20 skew above is a reading, not a finding: no gate is wrong
and nothing is unenforced. It is recorded here because the ruling's own
justification for text over hash is per-declaration readability, and at
21,000 lines a declaration that argument thins out.

## 维护者速读(草稿)

**改了什么。** `@objectstack/spec` 从今天起为它的**每一个**公开导出留一份"形状快照" —— 不是哈希,而是打包后
`.d.ts` 里那段声明原文,按入口点分成 17 个文件签入仓库,并配一道 CI
闸门:重新生成后对不上就红,失败信息里直接给出重新生成的命令。同时退休了旧的 27 条签名哈希文件。

**为什么改。** 原来的 pin 只记"某个名字还在不在",5336 行里只有 27
行能看出"形状变没变"。也就是说:把一个接口字段改名、砍掉一个联合成员、改一个函数签名 —— 这些都是会让客户升级后编译失败的破坏性改动 ——
全部一路绿灯。本次 PR 里有实测:改了 `JobRunOutcome` 的一个字段名之后,旧闸门 `check:api-surface`
退出码 **0**(看不见),新闸门退出码 **1**(点名了那个 interface)。路线是 2026-09-06 决策批次 objectstack-ai#60
里您逐字「同意」的那一条。

**风险与代价(含回滚)。** 代价是体积:12.08 MiB 文本、23.7 万行,压缩后 1.02 MiB,相当于 npm 包增长约
5.8%。更值得注意的是分布极不均匀 —— 最大的 4 个 schema 各自超过 2 万行声明文本,一旦它们变动,复核席位面对的是一份 2
万行的 diff;而裁决选"文本不选哈希"的理由恰恰是"diff 可读"。这一点我按实测如实报告,未自行改动路线。回滚成本很低:从
`files[]` 去掉一行即可停止随包发布;整道闸门回滚就是撤销本 PR,不留任何数据迁移。

**席位意见。**

**你要做的。** 只有一件事需要您判断:12 MiB / 23.7 万行这个量级,以及最大 4 个 schema 的 diff
可读性,是否仍符合当初选 A 方案时的预期。若认为需要收窄,那是裁决层面的一次增补,不是本 PR 的返工。其余部分已按裁决落地并自证。

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… TEST_DEBT graduation remedy (objectstack-ai#19031)

Fixes objectstack-ai#18953

Clause-②: no

`skip-changeset` — nothing published moves, and that is measured below
rather than asserted.

## What this lands

The `TEST_DEBT` graduation remedy printed by
`scripts/check-type-check-coverage.mjs` offered
two routes. Route (b) — drop the `**/*.test.ts` entry from
`tsconfig.json`'s `exclude`, or
widen `include` to reach the test tree — had its availability decided
per package by
`check:type-source-resolution`. That gate was retired under the
maintainer ruling of
2026-09-18 on objectstack-ai#18373 (PR objectstack-ai#18946), and what it had measured did not
retire with it: RED for
14 of the 18 entries that had an exclusion to drop.

The maintainer ruling on this card (comment

objectstack-ai#18953 (comment)
—
decision batch objectstack-ai#159 item 4, letter ②, maintainer verbatim 「同意」), quoted
as it stands:

> The remedy text names (a) only; (b) is removed from the printed
prescription and from any
> doc that restates it. An author who wants to widen `include` to the
test tree does so on
> their own judgement, ⛔ not on the gate's advice.

Arm ① (have this gate measure the precondition itself) and arm ③ (prose
suffices) were both
rejected in that same ruling. ⛔ Nothing here measures anything new, and
no verdict, count or
floor moves.

## The diff, file by file

**`scripts/check-type-check-coverage.mjs`**

- `graduationRemedy()`'s `TEST_DEBT` branch now prints route (a) alone —
the objectstack-ai#5286 sibling
route, marked as the only route this gate prescribes. The route-(b)
sentence, and with it
the retired gate's name, the `SHRINK-ONLY` clause and the 14-of-18
warning, are gone from
  the printed text.
- The `objectstack-ai#11491` design note above it records the withdrawal, the ruling
it came from, and that
  the way back in is closed from both sides.
- Three ledger notes that restated route (b) as a live option are turned
into records of
history: the `SINCE MEASURED` note (which said the message "no longer
offers the exclusion
route **without its precondition**" — now: not at all), the
`trigger-record-change`
graduation note ("the two remedies **are** interchangeable only
where…"), and the
`@objectstack/verify` note, whose "ROUTE (b)" label had no definition
left to point at.
- The `absent`-needle failure line no longer says "which is the other
ledger's remedy": that
was true while every anti-content needle was a cross-ledger one, and
reads false on all four
  failures the ablation below produces.

**`packages/verify/tsconfig.test.json`** — the other carrier the
ruling's "any doc that
restates it" reaches. It said the exclusion edit "WAS AVAILABLE HERE AND
WAS NOT TAKEN, which
is worth recording because `check-type-check-coverage.mjs` tells the
next reader to assume it
is not". That clause is false the moment the gate says nothing about the
route in either
direction. The measurement it carries (green for this package at
`1d67130585`, under a
trap-restored mutation) is kept as a reading on one tree, explicitly not
as a standing
clearance, and the reason the edit was declined here — vitest's module
semantics, never
availability — is unchanged.

## The collision, and the floors it could not move

`scripts/check-type-check-coverage.mjs:6407-6416` pinned the withdrawn
message in a self-test
case whose own `why` ended "these needles stay exactly as they were" —
written in PR objectstack-ai#18946,
about two and a half hours before this card was dispatched, on the
premise that (b) would stay
and be warned about in prose. Ruling ② overturns that premise, so the
case is **rewritten, not
deleted**, and turned from a presence-only assertion into ANTI-content:

```
present: ['tsconfig.test.json', 'the ONLY route this gate prescribes'],
absent:  ['exclude', 'widen', 'check:type-source-resolution', 'SHRINK-ONLY'],
```

Four needles rather than the retired gate's name alone, because that
name is only one of the
spellings the route could come back under. The unrecognised-ledger
case's `TEST_DEBT` needle
moves the other way for the same reason: it named
`check:type-source-resolution`, a string no
branch can emit any more, and now names `tsconfig.test.json`, which one
can.

No floor moved, and each half is checkable:

| floor | before | after |
|---|---|---|
| graduation cases in the `gradCases` table | 5 | 5 |
| `SELF_TEST_BATTERIES['graduation remedy (objectstack-ai#11491)']` | 8 | 8 |
| `SELF_TEST_BATTERY_FLOOR` (roster size) | 62 | 62 |
| printed self-test tally | 55 / 97 / 56 / 28 / 19 / 18 | 55 / 97 / 56 /
28 / 19 / 18 |

## Ablation — the rewritten case really fails

One-shot, trap-restored, run from the committed state at `0a406118e`,
hashes compared against
the `HEAD` blob (`scripts/…` is run by node directly, so there is no
`dist` leg):

```
anchor occurrences before: 1
ABLATION-MARKER occurrences after: 1        # the mutation reached disk
anchor occurrences after: 0
HEAD_HASH=479aa36f4dea2cc7b323ca0ab630ab1c85f3ead6 MUT_HASH=af87398715edb0b5e519bd285d468d81d747e004
ABLATED_SELFTEST_EXIT=1
  ✗ 4 failure(s) — message STILL contains exclude / widen /
    check:type-source-resolution / SHRINK-ONLY
BACK_HASH=479aa36f4dea2cc7b323ca0ab630ab1c85f3ead6   # byte-identical restore
RESTORE: git diff HEAD is empty
ABLATION-MARKER after restore: 0
RESTORED_SELFTEST_EXIT=0
```

Direction as predicted: handing route (b) back turns the case RED on all
four anti-content
needles. No ablation file is left behind.

## Verification

Exit codes captured before any pipe, in-worktree, at `bbd5e225a`.

- `pnpm check:type-check-coverage` — **0** before the change and **0**
after; its
`--self-test` — **0** before and **0** after, with the same printed
tally both times.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, derived
in-worktree from the merge base `64de4c576`: **59 families**, all 59
run, all exit **0**,
reconciled with `--ran`: `59 derived famil(ies) accounted for — 59 run,
0 NOT-MEASURED
(a DERIVED zero — all 59 recorded an exit code and none of them is 3)`.
That includes `pnpm check:pm-dispatch-gates` (**0**, 1849 self-test
cases, 1014.7s on a
contended box, run detached and waited on with `tail --pid`), `pnpm
check:type-check-debt`
(**0**, 4 ledger entries re-measured, 53 raw errors, none above its
recorded number),
`pnpm check:ratchet-remedy-authority` (**0**) and `pnpm check:nul-bytes`
(**0**).
⛔ Its own warning stands: that list is not a complete account of what CI
runs here.
- Four of those families first answered **exit 3** (`check:dts-closure`,
  `check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`) — PREREQUISITE NOT MET, not a
finding. A full
  `turbo run build --filter=!@objectstack/docs --concurrency=2` under
`scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`, 73/73 tasks)
and all four re-ran
  at **0**.
- `pnpm lint` (`eslint . --no-inline-config`, the whole repo, no
narrowing) — **0**.
- Control characters: `grep -naP` over both changed files finds none.

## Changeset

`skip-changeset`, measured on what each package's `files[]` actually
ships, with a positive
control:

- `scripts/check-type-check-coverage.mjs` — no owning package manifest
at all; shipped by
  nothing.
- `packages/verify/tsconfig.test.json` — owner `@objectstack/verify`,
whose `files[]` is
`['dist', 'README.md', 'CHANGELOG.md']`. The path matches **NONE** of
them. Positive
control on the same matcher: `dist/index.js` matches `dist`, so the
reader has a pulse.
- Second leg, on the built tree: the new text (`the ONLY route this gate
prescribes`) appears
in **0** files under `packages/verify/dist`; positive control
`bootStack` is found in three
  of them.

The only other edit is a comment inside a config that `tsc --noEmit`
reads, which no build
output can carry.

## Acceptance notes

Residuals found while working here, reported for the PM to file or drop
— ⛔ none of them is
fixed in this PR:

- **`gradCases[0]`'s `absent` needle `'drop the test exclusion'` is a
phantom check, and was
one before this PR.** No branch of `graduationRemedy()` has ever emitted
that exact string
(the withdrawn text spelled it `Drop the \`**/*.test.ts\` entry from
\`exclude\``), so the
DEBT case's anti-content assertion can never fail. Probe: delete the
needle and the
self-test stays green. Left alone deliberately — it is not this ruling's
subject, and this
PR neither created nor worsened it. Dedupe words: phantom absent needle
· graduation remedy
anti-content · drop the test exclusion literal · gradCases DEBT case ·
never-matching
  self-test needle.
- **`scripts/check-type-check-coverage.mjs:939` still calls a retired
gate a present tense
reader** ("leaving `tsconfig.json` — the only config that gate **reads**
— untouched").
Pre-existing tense drift from the objectstack-ai#18373 retirement, one sentence above
one this PR does
touch; noted, not filed, and the seat that next edits that paragraph is
the natural
  carrier.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
objectstack-ai#19117)

Fixes objectstack-ai#18263

Clause-②: no

## The defect, stated as it measures rather than as the title spells it

The card's title says "an entirely empty check-run output". Measured
later on the same card (comment `5705401851`, PR objectstack-ai#18524, run
`104955982460`), the failing `Check Changeset` answers `output.title` =
null with 0-byte `summary` and `text` and carries **`annotations_count`
= 1** — the runner's own generic exit-code annotation (`path .github`,
`level failure`, `title ''`, `message 'Process completed with exit code
1.'`). So the accurate description is **not** "an empty output"; it is
**"one generic annotation that states no cause"**.

That distinction is what makes this fix cheap. The annotation channel is
already open and already carried by this job, and a plain `run:` step
owns exactly one way onto it — the `::error::` workflow command, whose
sibling `::notice` this script has emitted for years. The gate's prose
refusals — among the best in the repo, naming the missing reading,
quoting the offending line and spelling the remedy down to "this red
clears with no push and no re-run" — reach the job log and are then
discarded at the check-run boundary. The reason is produced and thrown
away; the repair is to say it.

## What changed, entirely inside `scripts/check-changeset-no-major.mjs`

1. **Every near miss now renders its REASON.** `readClause2Line` has
always returned `spelling` / `inline-key` / `describing` beside the
offending line; this gate printed "a near miss" and dropped the reason.
It now prints the reason, the line, and the remedy that reason owes —
three different sentences, because `check-clause2-carriers.mjs` says in
its own words that "⛔ The reason changes the sentence, never the state".
2. **Every refusing lane emits exactly one `::error::` annotation**, so
the diagnosis crosses the check-run boundary onto the channel the run
already carries. Greens emit none.

The reason is **interpolated, never matched against a list held here**.
`CLAUSE2_NEAR_MISS_REMEDIES` is a lookup *from* whatever the reader
produced, and its miss path is loud: a reason this file has never been
taught still prints, still names the offending line, and still says
where the reason came from. That direction is deliberate — the reader is
a live surface (PR objectstack-ai#18903 is open on it, +225/-41), and a gate that
matched reasons against a frozen list would answer a new one with
exactly the silence this card is about.

Exactly one annotation per refusal, deliberately: a check run caps
annotations at ten per level, and this script has been past that cap
before (its stock-scoped predecessor emitted 171 `::notice` lines on PRs
that introduced none of them). And the annotation is **not** conditioned
on `GITHUB_ACTIONS` — `render` and `renderLevel` are pure by design,
which is what lets the self-test assert the MESSAGE rather than the exit
code, and an env read inside them would make the one thing this PR adds
the one thing the fixtures cannot see.

### What did NOT change

- **No `.github/workflows/**` file is touched.** The fix lives in the
`.mjs`, as the card asked.
- **Which bodies are ACCEPTED is unchanged.** `CLAUSE2_KEY_LINE` and
`readClause2Line` are not edited,
`scripts/pm/check-clause2-carriers.mjs` is not edited, and no verdict
moves. objectstack-ai#16303 remains open on the accept-set question and no arm of it
is implemented here. This PR makes the existing verdict legible and
nothing else.

## Post-condition 1 — the real predicate drives every row, with a
negative control that can fail

Every row below was produced by importing `readClause2Line` (the same
function the gate calls) and running the real `judgeLevel` /
`renderLevel`. ⛔ No hand-written matcher anywhere.

| body line | `readClause2Line` | exit | reason now in the emitted
annotation |
|---|---|---|---|
| `## Clause-②: no — …` (heading) | `near-miss` / `spelling` | 1 |
`spelling` + remedy |
| `` `Clause-②: no` · `skip-changeset` `` | `near-miss` / `describing` |
1 | `describing` + remedy |
| ``Domain: `domain:devx` · Clause-②: no`` | `near-miss` / `inline-key`
| 1 | `inline-key` + remedy |
| `Clause-②: no` (bare, own line) | `declared` / `no` | 0 | no
annotation — a green annotates nothing |
| NEGATIVE CONTROL `nothing here` | `null` | 1 | "the PR body carries no
`Clause-②:` line" — and no remedy, because there is no line to remedy |

The emitted text, for the heading shape (one line, escaped, abridged):

```
::error title=Check Changeset (level axis)%3A no readable `Clause-②%3A` declaration%2C and it is
the reading this PR needed::… %0A· declaration line: a near miss, not a declaration — reason
`spelling` — ## Clause-②: no — nothing published moves here%0A· remedy for `spelling`: the line
does not carry `Clause-②:` in the fixed spelling at the start of a line. The reader tolerates a
`- `, `* `, `> ` or `**` prefix and NOTHING else, so a markdown HEADING is a near miss and not a
declaration. Write it bare, on a line of its own.%0ADECLARE IT: …
```

## Post-condition 2 — a reason the code has no message for still prints

Driven through the exported
`nearMissReadings('a-reason-this-file-has-never-been-taught', …)`:

```
· declaration line: a near miss, not a declaration — reason
  `a-reason-this-file-has-never-been-taught` — ## Clause-②: no — the line that would otherwise be lost
· remedy for `a-reason-this-file-has-never-been-taught`: this gate carries no remedy sentence for a
  near miss of reason "a-reason-this-file-has-never-been-taught" — `readClause2Line`
  (scripts/pm/check-clause2-carriers.mjs) reports a reason this file has not been taught, and the
  reason plus the line are printed rather than swallowed. The offending line is: … Add the sentence
  for this reason to CLAUSE2_NEAR_MISS_REMEDIES in scripts/check-changeset-no-major.mjs.
```

Control: the same call with a KNOWN reason returns a different sentence,
so the miss path is not silently borrowing a known remedy — a wrong
prescription is worse than a named gap. `null` and `undefined` reasons
also return a sentence; an empty remedy would be this card's defect
moved one function along.

## Post-condition 3 — the two refusals, side by side

| refusal | where it is emitted | annotation |
|---|---|---|
| Clause-② declaration unreadable (near miss, or absent) | this script,
level axis | `::error title=Check Changeset (level axis)%3A no readable
`Clause-②%3A` declaration…` with the reason, the line and the remedy in
the message |
| declared `yes`, no moved package graded `minor`+ | this script, level
axis | `::error title=Check Changeset (level axis)%3A clause-② declares
YES while no moved package is graded `minor` or above…` |
| this PR adds no changeset | `pr-automation.yml`, `Require a changeset`
step, **unchanged by this PR** | `::error::This PR adds no changeset.
FIRST: …` |

A near miss and an absent line share a verdict (`not-measured-material`)
and used to share every byte anyone outside the run could read; they now
differ in the message, which the self-test pins. The missing-changeset
refusal is the workflow's own and already carried an `::error::`; the
self-test now **pins that it keeps one**, because without it that
refusal and this script's are once again one event from outside.

## Post-condition 4 — self-test and battery floor, before and after

| reading | before (`07c6f822e`) | after |
|---|---|---|
| `node scripts/check-changeset-no-major.mjs --self-test` | exit **0**,
299 assertions | exit **0**, 335 assertions |
| `SELF_TEST_BATTERY_FLOOR` (pinned roster size) | **18** | **19** |
| declared batteries | 18 | 19 |
| `'Missing input is a failure, never a pass (objectstack-ai#4690 / objectstack-ai#7006)'` floor |
**5** | **6** |

**Both floor moves are reported rather than absorbed, and neither is a
battery shrinking.**

- The roster grows by one because this PR declares one new battery,
`'objectstack-ai#18263: the refusal says its reason, and says it where the API can
read it'` (35 cases). The floor pin is the roster's own size, so
declaring a battery necessarily moves it; leaving it at 18 would let the
new battery be deleted later with nothing going red. The mechanism was
exercised in the process: the run before the roster entry existed failed
with *"registered 35 case(s) but is not declared in
SELF_TEST_BATTERIES"*.
- The `objectstack-ai#4690 / objectstack-ai#7006` battery goes 5 → 6 because one assertion there was
split into two. The old one was `render(unreadable).stdout.length ===
0`; what objectstack-ai#4690 forbids on stdout is a **tick**, and the `::error::`
annotation is the opposite of one, so the pin is now spelled as what it
always meant — every stdout line must start with `::error `, and there
must be exactly one of them. It is strictly stronger than the line it
replaces, not a relaxation.

## Reverse verification — three ablations, each proving the new battery
can fail

Each leg mutates the committed file, proves the mutation reached disk by
an anchor count, runs the self-test, then restores with `git checkout
HEAD -- …` and proves the restore by `git hash-object` against the HEAD
blob. A `trap … EXIT INT TERM` carries the restore on the crash path.
Predicted direction for all three: RED.

| leg | mutation | on-disk proof | self-test |
|---|---|---|---|
| A | delete the `::error::` annotation from the `not-measured-material`
lane | anchor `title: 'Check Changeset (level axis): no readable` 1 → 0
| **exit 1, 9 failures** |
| B | collapse the three near-miss remedies into one shared sentence |
anchor `Object.prototype.hasOwnProperty.call(CLAUSE2_NEAR_MISS_REMEDIES,
reason)` 1 → 0 | **exit 1, 3 failures** |
| C | make the unknown-reason path return an empty remedy | injected
marker 0 → 1, with the not-deleted control ` return (` held at 1 → 1 |
**exit 1, 2 failures** |

The failures name themselves. Leg A reds nine cases including *"a body
that ALMOST declared and a body that never tried produce different
text"*. Leg B reds *"the three near-miss reasons owe three DIFFERENT
remedies — one shared sentence would pass every assertion above while
reading no reason at all"*. Leg C reds *"a reason of `null` or
`undefined` still returns a sentence — an empty remedy is this card's
defect moved one function along"*.

**Leg C's first attempt was a proven no-op and its reading was
discarded, not retried quietly.** Its anchor was the text the mutation
inserts a copy of, so `grep -c` read 1 before and 1 after and the
harness refused to read a self-test result it could not prove had run.
It was re-run with an injected unique marker (0 → 1) and a control on
the text that must NOT vanish. The first attempt produced no reading at
all; the row above is the second.

Restores are proven, not assumed: each leg ends with `git checkout HEAD
-- …` (never a bare `git checkout --`, which would take the mutation
back out of the index) and then `git hash-object` against the HEAD blob
`af36b25de84a4e55c34ba323c83097c61a3f474c`, plus `git diff HEAD` empty
and the injected marker counted back to 0. All ran in a throwaway
detached worktree off this branch's commit, since the file under test is
the file the ablation mutates; that worktree is removed.

## Scope, changeset and labels

`skip-changeset`, measured rather than assumed. The diff is one file,
`scripts/check-changeset-no-major.mjs`. Resolving every tracked
`package.json` (83 tracked, 70 publishable — not private and carrying a
`files[]`) and asking which `files[]` entry would ship that path:
**zero**. Positive controls through the same resolver:
`packages/spec/dist/index.js` resolves to `@objectstack/spec`'s `dist`
entry and `packages/cli/dist/index.js` to `@objectstack/cli`'s, so the
resolver does find a shipped path when one exists;
`packages/spec/src/index.ts` correctly resolves to nothing. The
repo-root manifest is `private: true` with no `files`. Nothing published
moves, so this takes the label and not an empty changeset (workflow
route 2), and route 0 does not apply — this PR touches no
`.changeset/*.md` at all.

## One measured correction to the card's reproduction table

The card's table gives PR objectstack-ai#18959 as `` `Clause-②: no` `` on its own
line, backtick-wrapped, reading
`{"kind":"near-miss","reason":"describing"}`. Driven against
`origin/main` today, that exact line reads
**`{"kind":"declared","value":"no","arm":null}`** — a declaration, not a
near miss. `clause2LineDescribes`'s QUOTED-AND-CONTINUED tell fires only
when the backtick span opened at the key **continues past its closing
tick**; a span that closes with nothing after it is not describing. The
five PR bodies have all since been corrected, so the historical bytes
are no longer readable through the REST API and the exact line objectstack-ai#18959
carried could not be recovered — most likely it carried trailing content
after the closing tick, which is the `describing` shape and is covered
by the objectstack-ai#18946 row.

This narrows one row of the reproduction table; it moves nothing about
the card. Both near-miss spellings the card names are reproduced above
from the real reader, the third (`inline-key`) with them, and the remedy
is unchanged.

## Out of scope, noted here rather than filed

- `.github/workflows/pr-automation.yml` is untouched. Its `Require a
changeset` step's `::error::` is now **pinned** by this script's
self-test, which is a new coupling in the direction the card wants: it
is what keeps a missing-changeset refusal distinguishable from this
script's.
- The two other gates that run in the same job —
`check-empty-changeset.mjs` and `check-adr-0087-registration.mjs` —
refuse to the job log with no annotation of their own, exactly as this
one did. Same shape, different surface, and out of this card's file
surface.

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

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants