Skip to content

ci(release): 让发布完整性守卫真的能触发,并把镜像也纳入它守的不变量 (#4900) - #4901

Merged
os-zhuang merged 1 commit into
mainfrom
claude/ci-errors-i1t8fi
Aug 3, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/ci-errors-i1t8fi

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

修 #4899 引入的那个守卫的两处缺陷 —— 紧接着的下一次发布(89d2a4e)就把它们全暴露了。

那一轮:npm 成功发布了全部 17.0.0-rc.2 并推了 tag,然后创建 @objectstack/spec 的 GitHub Release 时撞上 API 的 125k body 上限失败(该版本的 changelog 段落 342,911 字符,详见 #4900)。步骤标红 → published 没置真 → 运行时镜像被静默丢掉。

缺陷 1:守卫根本触发不了

GitHub 会给不带状态函数的 if: 隐式包一层 success()。所以

if: steps.changesets.outputs.published != 'true'

实际是 success() && (...) —— changesets 步骤一失败它就被 skipped,而那正是它存在的两个理由之一。改为 !cancelled() && ...。

缺陷 2:它守的契约错了

原来写的是「缺失就补发」。这覆盖不了「发布成功但在上报前挂了」:rc.2 当时已经在 npm 上,即使它执行了也是 no-op,镜像照样丢。

现在它守的是发布真正欠下的不变量 —— 本仓库的当前版本必须在 npm 上,且必须有对应的运行时镜像 —— 并把后半句通过 job output 上报,让 docker job 去建。

docker job 同样加 !cancelled()

默认隐式 success() 下,上游 job 一失败依赖 job 就被跳过 —— 于是镜像丢在了一个发生在包已经公开之后的故障上。真正的闸门是 published 输出,不是 release job 的退出码。run 仍然会红(GitHub Release 确实失败了),但镜像不再陪葬。

一个刻意的取舍

ghcr 探测失败按「镜像缺失」处理:多建一次镜像只是几分钟,而错误地跳过会留下一个「npm 上有包、没有镜像、且没有任何信号」的发布 —— 正是今天这个坑。

验证

四条路径都用打桩的 npm/pnpm/git/curl 实跑过:

场景 结果
npm 有 + 镜像有(正常路径) 完全 no-op,不写任何 output
npm 有 + 镜像缺(今天这次) 请求 docker,不重复发包
npm 缺(#4898 场景) 补发 → 再请求 docker
ghcr 探测失败 按缺失处理

ghcr 探测本身是对线上真实注册表验证的:能正常换到匿名 pull token(长度 68),并正确报告 17.0.0-rc.1 存在、17.0.0-rc.2 不存在。

YAML 解析通过,新步骤 shell 过 bash -n,两个 job output 表达式取值已核对。

关联

只动 release.yml,不涉及任何运行时代码。

🤖 Generated with Claude Code

https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf


Generated by Claude Code

…t guard the image too (#4900)

Two defects in the guard #4899 added, both exposed by the very next release
(89d2a4e). npm published all of 17.0.0-rc.2 and pushed its tags, then creating
the @objectstack/spec GitHub Release failed on the API's 125k body limit — the
spec changelog section for that version is 342,911 characters (#4900). The step
went red, `published` stayed false, and the runtime image was silently lost.

1. The guard could not fire. GitHub wraps an `if:` naming no status function in
   an implicit success(), so `if: steps.changesets.outputs.published != 'true'`
   was really `success() && …` — skipped for any changesets-step failure, which
   is one of the two cases it exists for. Now `!cancelled() && …`.

2. Its contract was wrong. "Publish whatever is missing" does not cover a
   release that published and then died before reporting it: rc.2 was already on
   npm, so a publish-only step would have no-opped and lost the image anyway. It
   now guards the invariant the release actually owes — this repo's version must
   be on npm AND must have a matching runtime image — and reports the second
   half through the job outputs so `docker` builds it.

`docker` gains the same `!cancelled()` treatment. A dependent job under the
default implicit success() is skipped for any upstream failure, so the image was
lost to a fault that happened after the packages were already public. The
`published` output is the real gate; the release job's exit status is not, and
the run stays red either way because the GitHub Release genuinely failed.

A failed ghcr probe counts as MISSING on purpose: a redundant rebuild costs a
few minutes, a wrongly-skipped one leaves a published npm version with no image
and nothing to say so.

Verified against stubbed npm/pnpm/git/curl — npm present + image present (full
no-op, no outputs), npm present + image missing (requests docker, does NOT
republish), npm missing (publishes, then requests docker), and ghcr unreachable
(treated as missing). The ghcr probe itself was checked against the live
registry: it resolves an anonymous pull token and correctly reports 17.0.0-rc.1
present and 17.0.0-rc.2 absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 3, 2026 2:58pm

Request Review

@os-zhuang os-zhuang added ci/cd tooling skip-changeset PR has no user-facing published change; bypasses the changeset gate labels Aug 3, 2026 — with Claude
@github-actions github-actions Bot added the size/s label Aug 3, 2026
@os-zhuang os-zhuang added tooling and removed size/s tooling labels Aug 3, 2026 — with Claude
@github-actions github-actions Bot added the size/s label Aug 3, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 3, 2026 15:07
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit db82f2e Aug 3, 2026
35 of 36 checks passed
@os-zhuang
os-zhuang deleted the claude/ci-errors-i1t8fi branch August 3, 2026 15:11
os-zhuang pushed a commit that referenced this pull request Aug 4, 2026
… nothing

Empty frontmatter — the repo's sanctioned "this PR releases nothing"
declaration, on par with the skip-changeset label (both are named in the
Check Changeset gate). The PR changes only .github/workflows/, root scripts/
and one check: entry in the root (private) package.json, so nothing reaches a
published package; a non-empty changeset would bump all 69 packages of the
fixed group in lockstep and burn an extra rc for no shipped product code.

The body records the one caveat that matters here: an empty changeset is the
exact input #4898 showed can jam a release, which is now bounded rather than
silent by the recovery step (#4899, made reachable by #4901) — and this PR is
what extends the GitHub Releases and the ADR-0087 D4 spec-changes.json
attachment onto that recovery path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
os-zhuang pushed a commit that referenced this pull request Aug 4, 2026
…releases nothing"

This reverts 1f50271, keeping the `skip-changeset` label as this PR's only
"releases nothing" declaration.

The empty changeset was redundant with the label — Check Changeset exempts a
labelled PR at the job level, and the earlier red run predated the label
(the PR was created at 15:54:54, the label applied at ~15:56, so that run's
event payload carried no labels at all). Any subsequent synchronize event
re-evaluates the job `if:` against current labels.

Redundancy is not free when the redundant copy is a known-dangerous shape. An
empty changeset is exactly the input #4898 showed can jam a release:
changesets/action reaches its publish branch only with ZERO pending changesets,
and an empty one still counts as pending. The argument that this is now bounded
rests on the recovery step (#4899/#4901) — which is a path THIS PR modifies. A
PR whose whole purpose is repairing the release machinery should not plant a
known-hazardous input and then lean on the very mechanism it is changing to
catch it. One declaration, via the label, and no hazard.

The gate text that recommends an empty changeset as the way out is tracked
separately as #5292.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…bjectstack-ai#4900) (objectstack-ai#5290)

* ci(release): build GitHub Releases ourselves, with bodies that fit the 125k limit (objectstack-ai#4900)

changesets/action's `createGithubReleases` posts each package's raw CHANGELOG
section as the Release body. @objectstack/spec's section for a single v17 RC is
342,893 characters against the API's 125,000 limit, so the POST 422'd — inside
runPublish, i.e. after `changeset publish` had fully succeeded but BEFORE the
action set its `published` output. The step went red, `published` stayed false,
and the docker job gated on it was skipped: a published npm version with no
runtime image.

The section only grows, so this failed identically every release in the window.
Measured against the live API: @objectstack/spec has NO Release for
17.0.0-rc.0, rc.1 or rc.2 (all 404 by tag), while 16.0.0 and 16.1.0 — 62,886
and 1,523 characters — have theirs, each carrying the ADR-0087 D4
spec-changes.json asset. That asset uploads ONTO the spec Release, so D4 has
been silently unmounted for the whole v17 RC window too, not just the Release.

`createGithubReleases: false`, and scripts/release-github-releases.mjs does the
job instead. It is faithful to what the action produced — same tag, name,
prerelease rule, and a direct port of the action's own getChangelogEntry for
the body, which reproduces the real @objectstack/cli@17.0.0-rc.2 release body
byte for byte (73,993 chars) — plus the three properties it lacked:

  - Bounded. An over-limit body is cut on a line boundary, any code fence the
    cut opened is closed so the notice renders as markdown rather than inside a
    code block, no surrogate pair is split, and both ends carry a link to the
    complete entry in CHANGELOG.md at the release commit. Cost is measured in
    UTF-16 code units, which is >= the code-point count for every string, so it
    can only over-estimate against whichever definition of "character" the API
    applies (the failing section is 342,893 characters but 359,636 UTF-8 bytes;
    the API quoted the former).
  - Idempotent. Looks the release up by tag and PATCHes when it exists, POSTs
    when it does not. rc.2 left ~69 of 70 releases created, so recovering over
    a partial set is the normal case, not the exception.
  - Isolated per package. The action ran the set through one Promise.all, so
    the first rejection abandoned the rest. This runs sequentially, collects
    failures and still exits non-zero, so one bad changelog can no longer cost
    @objectstack/spec its Release — and D4 its mount point.

Turning createGithubReleases off also disables the action's per-tag `git push`,
which lives in the same block. That is a bonus: scripts/release-publish.sh
already pushes every tag in one atomic `git push origin --tags` precisely
because those concurrent per-tag pushes raced GitHub's ref backend (objectstack-ai#2191).

Both publish paths are covered. The recovery step (objectstack-ai#4901) now reports
`npm-published` separately from `published` — the former means "packages went
out and owe Releases", the latter "the docker job must build" — and emits its
version unconditionally, since an npm repair whose image happens to exist still
owes its Releases. release-spec-changes.sh takes that version as a fallback, so
D4 mounts on the recovery path as well, which it never could before.

`pnpm check:release-body` runs the script's --self-test in lint.yml: 49
assertions over the real code path, fed the REAL oversized section out of
packages/spec/CHANGELOG.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t

* chore(changeset): declare that objectstack-ai#4900's release-machinery fix releases nothing

Empty frontmatter — the repo's sanctioned "this PR releases nothing"
declaration, on par with the skip-changeset label (both are named in the
Check Changeset gate). The PR changes only .github/workflows/, root scripts/
and one check: entry in the root (private) package.json, so nothing reaches a
published package; a non-empty changeset would bump all 69 packages of the
fixed group in lockstep and burn an extra rc for no shipped product code.

The body records the one caveat that matters here: an empty changeset is the
exact input objectstack-ai#4898 showed can jam a release, which is now bounded rather than
silent by the recovery step (objectstack-ai#4899, made reachable by objectstack-ai#4901) — and this PR is
what extends the GitHub Releases and the ADR-0087 D4 spec-changes.json
attachment onto that recovery path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t

* Revert "chore(changeset): declare that objectstack-ai#4900's release-machinery fix releases nothing"

This reverts 1f50271, keeping the `skip-changeset` label as this PR's only
"releases nothing" declaration.

The empty changeset was redundant with the label — Check Changeset exempts a
labelled PR at the job level, and the earlier red run predated the label
(the PR was created at 15:54:54, the label applied at ~15:56, so that run's
event payload carried no labels at all). Any subsequent synchronize event
re-evaluates the job `if:` against current labels.

Redundancy is not free when the redundant copy is a known-dangerous shape. An
empty changeset is exactly the input objectstack-ai#4898 showed can jam a release:
changesets/action reaches its publish branch only with ZERO pending changesets,
and an empty one still counts as pending. The argument that this is now bounded
rests on the recovery step (objectstack-ai#4899/objectstack-ai#4901) — which is a path THIS PR modifies. A
PR whose whole purpose is repairing the release machinery should not plant a
known-hazardous input and then lean on the very mechanism it is changing to
catch it. One declaration, via the label, and no hazard.

The gate text that recommends an empty changeset as the way out is tracked
separately as objectstack-ai#5292.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 23, 2026
…ct-blocks tier (objectstack-ai#9392) (objectstack-ai#9728)

Catch-up with the registry inputs objectui#4648/objectstack-ai#4901 published on the
object-form registration: 14 keys are declared on the react-blocks
ObjectForm overlay (descriptions adapted from objectui's registration),
and 3 are baselined with recorded reasons per the maintainer's 2026-08-18
mixed-disposition ruling — initialData (alias of initialValues), mobile
(internal override), navigateOnSuccess (parked pending the action-success-
navigation family; revisit tracked on objectstack-ai#9392).

The parity baseline is regenerated via --update, which also snapshots the
six SDUI object-* blocks (objectstack-ai#7751) as additive coverage; the clean-baseline
test now derives that tail from the committed file.


Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…mes a known repository, so pre-#N / post-#N are judged and framework#N is this repository (objectstack-ai#20554)

Fixes objectstack-ai#20330
Clause-②: no

## What changed

`scripts/check-issue-citations.mjs` read ANY `word#N` as a repository
reference, so `pre-objectstack-ai#12248`, `post-objectstack-ai#6640`, `Pre-#N`, `POST-#N` and
`Framework#N` were classed cross-repo and never judged. Its qualifier is
now a **closed set**. A token joined to `#N` names a repository only
when it is an `owner/repo` form or a name in the new
`KNOWN_REPOSITORIES` table, matched case-insensitively. Any other prefix
is prose, and the number after it is this repository's and is judged.

One recogniser, `repositoryOf`, is asked in all three places: by
extraction, by the board's probe set (`boardWanted`) and by the
classifier (`namesThisRepository`). The three can no longer disagree
about a token.

- `KNOWN_REPOSITORIES` rows: `objectstack` and `framework` (both THIS
repository), `objectui`, `ui` (an objectui alias, joined form only),
`cloud`, `hotcrm`, `hotcrm-heimao`, `os-tianshun-mtc` and
`os-project-titanwind-ehr`. Each row carries the measurement that put it
in.
- **Prose form.** `objectui PR objectstack-ai#10264`, `objectui objectstack-ai#2670`, `cloud objectstack-ai#2937`
and `framework objectstack-ai#2679` read as their repository. The form is a known
repository name, whitespace, an optional `PR` or `issue`, then `#N`. The
alias `ui` is excluded from it, because `UI #N` is ordinary English.
- **No carry across a pair.** In `objectui#6110 + objectstack-ai#6111` the second
number stays this repository's. The convention is to qualify each
number, and the one live site in the claim's surface is respelled:
`packages/spec/src/data/field.zod.ts:370` now reads `objectui#6110 +
objectui#6111`. This is comment-only, with a `patch` changeset.
- **Ordinal heads.** Closing the set exposes ordinals that were hidden
behind a fake qualifier. `NON_CITATION_HEADS` gains `OQ` (`ADR-0076
OQ#10`, 10 sites) and `PKCS` (`PKCS#11`, 1 site). A hyphen joining a
head to its `#` is now read as the same head (`Prime-Directive-objectstack-ai#12`, 2
sites). `PD#12` (8 sites) was already covered by the existing `pd` head
once its candidate is refused.
- **Refusal text.** The `REMEDY` text now states the grammar that judged
the author.

### A false red in the same seam, fixed because this change would have
widened it

`buildBoard` probed only UNQUALIFIED numbers, so a diff adding
`objectstack#N` was classified against a board that never asked about N.
Reproduced on unmodified `288611e3e5`: I appended `objectstack#20330`
(this live card) to a swept file and ran `node
scripts/check-issue-citations.mjs`. It exited **2**, reading `board:
probed (0 citations)` and `[allocated-but-absent] ...
objectstack#20330`. Reading `framework#N` as this repository would have
inherited that false red on every site. The probe set is now
`boardWanted`, meaning every citation judged here. The self-test pins it
through `probeBoard` over a stub. The blocking rule is unchanged:
findings still exit 2.

## Measurements the design rests on

- **`framework` names this repository.** `git ls-remote
https://github.com/objectstack-ai/framework` answered HEAD `288611e3e5`,
identical to `objectstack-ai/objectstack`. The controls diverged: a
nonexistent name under the same owner exited 128, and
`objectstack-ai/objectui` answered its own HEAD `0eb9f36aca`. The REST
and web routes to `framework` answered 403 from this session's proxy
(bound to configured repositories), so git's rename redirect was the
readable instrument. `framework#N` / `Framework#N` therefore read as
THIS repository.
- **Qualifier census on `288611e3e5`, over the declared surfaces.**
There were 27 distinct candidates behind 1,655 sites:
- This repository: `framework` 255, `objectstack` 111,
`objectstack-ai/objectstack` 9, `Framework` 2.
- Siblings: `objectui` 672, `cloud` 213, `hotcrm` 24,
`objectstack-ai/objectui` 12, `ui` 11, `objectstack-ai/cloud` 9,
`better-auth/better-auth` 3, `os-tianshun-mtc` 2, and 1 each of
`hotcrm-heimao`, `os-project-titanwind-ehr`, `objectstack-ai/objectos`,
`objectstack-ai/ats` and `objectstack-ai/hotcrm`.
- Prose: `pre-` 284, `post-` 12, `Pre-` 4, `Post-` 4, `PRE-` 1 and
`POST-` 1.
  - Ordinals: `OQ` 10, `PD` 8, `Prime-Directive-` 2, `PKCS` 1.
- **`ui` is objectui.** `objectstack-ai/ui` does not exist, and
`ui#6837`, `ui#6206` and `ui#6207` are objectui's records on its board
(`objectstack#6206` answers 404, so it would have been a false death).
- **Prose form, 27 sites.** For every objectui number I read objectui's
board and this repository's. The objectui record is the one each
sentence describes: `objectui objectstack-ai#2670` is "Flow designer: render loop /
parallel / try_catch as nested", cited from `loop-node.ts`, and
`objectui PR objectstack-ai#4264` diagnoses a path on the right side of `==`, cited
beside `PATH_SHAPED_LITERAL`. This repository's same number is unrelated
on every site. The `cloud` sites could not be read (private) and follow
their context. There were zero false positives.
- **Pair carry, 48 sites. The measurement refuses a carry rule.**
- `,` and `and`: every cross-repo pair I could judge names THIS
repository's second number. `cloud#1013 and objectstack-ai#10645` is this repository's
cli `serve` issue (4 sites), `cloud#1020, objectstack-ai#5233` its org gate issue (6
sites), `objectui#2561, objectstack-ai#3021` its lazySchema PR, and `objectui#3136 and
objectstack-ai#14492` answers 404 on objectui.
- `/`: mostly carries, but not always. `objectui#3226 / objectstack-ai#4827` is this
repository's objectstack-ai#4827, a conversion entry handed over from objectui and
cited from `conversions/registry.ts`.
- `+`: exactly one distinct pair exists in the corpus, which is too thin
to establish a convention.

## Census of the newly judged spellings

Taken with the gate's own `--census --json` at `a3c14755f8` against an
enumerated board (184 pages, frontier objectstack-ai#20551). The per-site transition
comes from the gate's `--list` before and after the change. Dead means
`allocated-but-absent`. Four of the numbers (14657, 12998, 10194 and
8692) were re-probed directly and answered 404.

| spelling | sites now judged | dead |
|---|---:|---:|
| `pre-#N` | 284 | 26 |
| `framework#N` | 255 | 0 |
| `post-#N` | 12 | 0 |
| `Pre-#N` | 4 | 1 |
| `Post-#N` | 4 | 0 |
| `Framework#N` | 2 | 0 |
| `PRE-#N` | 1 | 0 |
| `POST-#N` | 1 | 0 |
| **total** | **563** | **27** |

Other readings, same run:

- **Newly deferred (25 sites):** the 24 prose-form sites plus the
respelled `field.zod.ts:370`. Four of them were base census deaths that
were never deaths: `objectui PR objectstack-ai#8758` three times, and the respelled
`objectstack-ai#6111`.
- **No longer extracted (21 ordinals):** `OQ#10` ×10, `PD#12`/`PD#10`
×8, `Prime-Directive-objectstack-ai#10`/`objectstack-ai#12` ×2 and `PKCS#11` ×1.
- **Whole-census tally:**
- Base `288611e3e5`: 38,109 judged. resolves 32,202 · resolves-as-pull
1,863 · cross-repo-unjudged 1,535 · allocated-but-absent 2,509.
- Branch `a3c14755f8`: 38,088 judged. resolves 32,689 · resolves-as-pull
1,891 · cross-repo-unjudged 976 · allocated-but-absent 2,532.
- The board moved between the two runs, so the per-site transition above
is the reading, not the tally difference. The cross-repo count
reconciles exactly: 1,535 − 563 − 21 + 25 = 976.

## Verification

All gates below ran at `a3c14755f8`, the branch head.

- **Self-test.** `node scripts/check-issue-citations.mjs --self-test`
exits 0 with 114 cases across 8 batteries (base: 73 cases across 7). The
new battery `qualifier` (floor 40) pins every spelling both ways: lit on
a live number and a FINDING on a dead one for `pre-` `post-` `Pre-`
`Post-` `PRE-` `POST-`, and for `framework` `Framework`
`objectstack-ai/framework` `objectstack`. It also pins cross-repo even
when dead for `objectui` `OBJECTUI` `ui` `cloud` `hotcrm`
`objectstack-ai/objectui` `better-auth/better-auth`, and covers:
  - an unknown word prefix read as prose;
  - the four ordinal heads;
- the prose form, lit and dead, including `PR #N` and `UI #N` NOT being
the prose form;
- the pair, no carry (dead second number red) and qualified number by
number (both deferred);
  - `boardWanted` and a probed-board resolution of `objectstack#20330`;
  - registry hygiene.
`live-corpus` gains a pin that every qualifier the live corpus keeps
names a repository.
- **Ablations.** Six mutations went through
`scripts/ablation-replace.mjs`, each landing on disk with the anchor
count 1 → 0 and the blob changed, each red on its own case, and each
restored with blob equal to HEAD `8b6cf12653dd` and `git diff HEAD`
empty:
- M1: the recogniser returns any bare candidate. The self-test reds on
"`pre-` is prose".
- M2: the probe set reverts to unqualified-only. It reds on "the board's
probe set".
- M3: the `framework` row is renamed. It reds on "`Framework` is THIS
repository".
  - M4: the hyphen head is off. It reds on "`Prime-Directive-objectstack-ai#12`".
- M5: the prose form is off. It reds on "`objectui PR #N` names
objectui".
  - M6: the `OQ` head is removed. It reds on "`ADR-0076 OQ#10`".
- **Diff-scoped verdict** (`node scripts/check-issue-citations.mjs`, as
CI runs it): exit 0 on this branch. It judged the respelled line's 2
citations, both `cross-repo-unjudged`.
- **One-time end-to-end proof** (no permanent test; injected uncommitted
and restored with blob equal to HEAD and `git diff HEAD` empty). I
appended `pre-objectstack-ai#12248` (dead) and `framework#20330` (live) to
`packages/cli/src/commands/generate.ts` and ran the diff verdict twice:
- The branch gate exits **2**. `pre-objectstack-ai#12248` is `allocated-but-absent`,
`framework#20330` resolves on a board `probed (2 citations)`, and the
respelled pair stays cross-repo.
- The `288611e3e5` gate, from a temporary copy, exits **0** with all 4
`cross-repo-unjudged`. That is the hole this closes.
- **Census** (`--census --json`): exit 0. It is report-only and never
fails.
- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 97 commands, and all 97
ran. 96 exit 0, including `pnpm check:pm-dispatch-gates`, whose
`dispatch-gates.mjs --self-test` passes 1,976 cases. That self-test pins
this file's `:204 local-env` declaration line, and every edit here stays
below it or is line-neutral. `--ran` reconciliation reads "97 derived
famil(ies) accounted for — 96 run, 1 NOT-MEASURED (1 DERIVED from a
recorded exit 3)".
- **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: it loads every
package's built CJS entry, and this box holds no dist for 80+ packages.
The diff changes no emitted code. `@objectstack/spec` was rebuilt, and
its entry gates (`check:browser-reachable-entries`,
`check:entry-nameability`) exit 0. CI's Lint and Repo Gates owns this
one.
- **Spec package.** `pnpm --filter @objectstack/spec typecheck` returned
VERDICT command-exit 0. See the report comment for the test run.

## Acceptance notes

- **Dead sites the census hands over, not rewritten here.** One is under
`packages/spec/src/**` and is input for the staged sweep on objectstack-ai#20234:
`packages/spec/src/meta-spelling/manifest-collection-spelling.ts:71`
`pre-objectstack-ai#10194`. The other 26 are outside `packages/spec/src/**`, and no
card names them:
  - `packages/cli/src/commands/generate.ts:1842` `pre-objectstack-ai#14657`
  - `packages/cli/src/utils/storage-driver.ts:206` `pre-objectstack-ai#6345`
- `packages/drivers/driver-sql/src/schema-drift.ts:2458`, `:2474`
`pre-objectstack-ai#12998`
- `packages/drivers/driver-sql/src/sql-driver.ts:3872`, `:16545`
`pre-objectstack-ai#17590`
- `packages/drivers/driver-sql/src/sql-driver.ts:18285`, `:18324`
`pre-objectstack-ai#12998`
  - `packages/drivers/driver-sql/src/sql-driver.ts:20095` `Pre-objectstack-ai#12380`
- `packages/drivers/driver-turso/src/remote-transport.ts:2624`
`pre-objectstack-ai#12380`
  - `packages/lint/src/validate-searchable-fields.ts:312` `pre-objectstack-ai#8404`
  - `packages/metadata-protocol/src/protocol.ts:2793` `pre-objectstack-ai#10888`
  - `packages/metadata-protocol/src/seed-loader.ts:1947` `pre-objectstack-ai#11674`
  - `packages/objectql/src/action-governance.ts:339` `pre-objectstack-ai#14423`
  - `packages/plugins/plugin-auth/src/auth-manager.ts:5629` `pre-objectstack-ai#14762`
-
`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts:266`,
`:630` `pre-objectstack-ai#8692`
- `packages/plugins/plugin-security/src/per-organization-catalog.ts:314`
`pre-objectstack-ai#8692`
-
`packages/plugins/plugin-security/src/permission-set-projection.ts:482`
`pre-objectstack-ai#6483`
-
`packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts:5`
`pre-objectstack-ai#14484`
  - `packages/runtime/src/domains/mcp.ts:364` `pre-objectstack-ai#8726`
- `packages/runtime/src/sandbox/body-runner.ts:548`, `:735` `pre-objectstack-ai#14758`
  - `packages/runtime/src/sandbox/script-runner.ts:440` `pre-objectstack-ai#14758`
  - `packages/types/src/driver-error-classification.ts:608` `pre-objectstack-ai#13324`
  - `packages/types/src/node.ts:1428` `pre-objectstack-ai#10943`
- **The same `objectui#6110 + objectstack-ai#6111` pair outside the claim's surface.**
It still reads `objectstack-ai#6111` as this repository's (404 here), so these are
existing census deaths: `packages/spec/src/ui/view.zod.ts:3634` (for the
objectstack-ai#20234 sweep),
`packages/metadata-core/src/form-predicate-root-policy.ts:14`, `:120`
and `:205`, and `packages/metadata/src/plugin.ts:910`. Each respells to
`objectui#6110 + objectui#6111`.
- **Pairs that read silently wrong, not dead.** Several `REPO#N / #M`
pairs name the qualifier's own second number, which resolves here as an
unrelated record. Examples: `hotcrm-heimao#35/objectstack-ai#40/objectstack-ai#59`,
`objectui#2715/objectstack-ai#2717`, `objectui#2711/objectstack-ai#2722`, `objectui#4648/objectstack-ai#4901`,
`objectui#5018 / objectstack-ai#6469`, `cloud#957 / objectstack-ai#962` and `cloud#930/objectstack-ai#944`. No
gate can see these, because they resolve. The convention in the refusal
text (qualify each number) is the remedy when someone next touches the
line.
- **Seat 4's `objectui PR objectstack-ai#10264` specimen.**
`packages/spec/src/api/export-job-family-retirement.test.ts:25` sits on
a DEFERRED surface (`packages/**/*.test.ts`), and `surfaceFor` answers
`null` for it. The census never judged that site. The prose form it
names is now read correctly wherever the census does look.
- **Observed once: a truncated board enumeration accepted as a
reading.** My first branch `--census` read `enumerated (126 pages)` with
frontier objectstack-ai#13977, against 184 pages and objectstack-ai#20551 on the re-run minutes
later, and reported 9,160 `never-issued` phantoms. `enumerateBoard`
stops at the first page without `rel="next"` and trusts the maximum it
saw as the frontier. This diff does not touch that code. The diff-scoped
verdict enumerates only past 400 distinct numbers. Recorded, not filed;
the seat decides.
- **Scope declaration.** `NON_CITATION_HEADS` (two rows) and
`nonCitationHead` (the hyphen) are grammar next to the qualifier, not
the qualifier itself. They are here because closing the qualifier made
those 13 ordinal sites judged citations of this repository's objectstack-ai#10, objectstack-ai#11
and objectstack-ai#12, which is false. No gate was added, and the diff-scoped blocking
rule is unchanged.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ned and URL-spelled citations, pinned in one spelling table (objectstack-ai#20989)

Fixes objectstack-ai#20636
Clause-②: no

The family closeout for the citation extractor in
`scripts/check-issue-citations.mjs`: every spelling a seat measured as
invisible to the diff gate and the census is now read, every exclusion
keeps only the shapes it was measured protecting, and the self-test
carries the one enumeration table the triage asked for (48 spellings,
each "extracted as" or "not a citation, because"). Landing site:
`scripts/check-issue-citations.mjs` only.
`scripts/check-doc-authoring.mjs` is untouched; H2 below says why.

## What changed

- **Hyphen after the number.** The lookahead no longer refuses a `-`, so
`objectstack-ai#13398-class`, `objectstack-ai#5347-A` and `ui#6206-B` read as citations of their
number. It still refuses a word character, so a hex colour stays out.
- **Slash before the `#`.** A `/` is now valid context before a bare
`#`. It stays refused only before a qualifier candidate, so a URL path
fragment such as `https://example.com/docs/page#12` never reads `page`
as prose. A `/` right after a digit is the exception, so
`objectstack-ai#3076/objectui#2614` still reads its own qualifier.
- **Slash-joined continuation.** In `#A/#B`, the second number takes the
chain head's reading: bare after a bare or prose head, the head's
repository after a qualified head, and an ordinal after an ordinal. Only
a joined `/` continues a chain. `objectui#1 / objectstack-ai#2` and `objectui#1 + objectstack-ai#2`
stay two separate readings.
- **URL spelling.** `https://github.com/OWNER/REPO/issues/N` and
`.../pull/N` are now citations, qualified by their own `OWNER/REPO`.
That puts `objectstack-ai/framework` in this repository and makes every
other repository's URL cross-repo, never a finding. Inside a markdown
link `[#N](URL)`, the citation counts once.
- **Head rows.** I retired `re-charter`, `clause` and `option` (named in
the thread), plus `acceptance` and the section mark (found by the same
measurement). Each protects 0 sites repo-wide at two or more digits and
hides board citations. The grammar's two-digit floor already keeps
one-digit ordinals out. I added a `](` row, the narrower guard the
hyphen exclusion leaves behind for markdown in-page heading anchors.
- **Self-test.**
  - A new `spellings` battery (56 cases) reads the table row by row.
- Every head row must excuse at least one table row, and every required
spelling (the card's list plus the thread's) must be present.
- The `live-corpus` battery gains three floors, one per new arm, each
counting only a number spelled once on its line.
  - The roster floor rises from 6 to 9 batteries.
  - Total: 114 cases in 8 batteries became 173 in 9.
- I edited the header in place and kept its line count, because
`scripts/pm/dispatch-gates.mjs`'s self-test pins
`scripts/check-issue-citations.mjs:204 local-env`. The marker is still
on line 204, and that pin passes (see Gates).

## H1: what each exclusion protected and hid

Measured on `3693a1b50` over the declared surfaces. Every arm was judged
against one enumerated board: 188 pages, frontier 20959,
2026-09-30T22:55Z. The instrument mirrors the gate's extractor and
matched it file for file on all 2,640 files (0 mismatches).

| exclusion | hid (sites, dead) | protected in the declared surfaces |
disposition |
|---|---|---|---|
| hyphen after the number | 58, 1 dead (8 cross-repo) | 0: no numeric
range, slug or hex-like token. Repo-wide: in-page heading anchors, 16
lines in `docs/design/**` and `skills/**`, plus one range,
`docs/audits/...md`, whose first number is a citation | dropped; the
`](` row keeps the anchor out |
| `/` before the `#` | 528, 10 dead: 523 `#A/#B` second numbers and 5
`TOKEN/#N` such as `ADR-0049/objectstack-ai#1888` | 0 paths and 0 URL fragments |
narrowed to the candidate arm; continuations read as their chain |
| head `re-charter` | 0 left on this tree (the 26 dead `re-charter
objectstack-ai#13135` were rewritten by PR objectstack-ai#20750) | 0; only the gate's own fixtures
used it | retired |
| head `clause` | 0 in the surfaces; 4 in deferred test files, all board
citations | 0 | retired |
| head `option` | 1 (`option objectstack-ai#14088`, live); 1 more under `scripts/**` |
0 | retired |
| head `acceptance` | 1 (`the silent acceptance objectstack-ai#6132 closed`, live) | 0
at two or more digits | retired (in-place, below) |
| head section mark | 0 in the surfaces; 4 in test files (`§6 objectstack-ai#11176's
decisions`) | 0 at two or more digits | retired (in-place, below) |
| heads kept | none measured hiding a citation | directive 203 (max 13),
`PD` 85 (max 13), batch 276 (69 distinct, 11 to 227), `OQ` 10, `PKCS` 1
| kept |

The 8 slash chains headed by another repository are not a case where the
two populations cannot be told apart. The 5 on objectui's public board
each name objectui's record, the issue and then the pull request that
fixed it, read one by one against both boards:

- `objectui#2715/objectstack-ai#2717`
- `objectstack-ai#2711/objectstack-ai#2722`
- `objectstack-ai#2725/objectstack-ai#2732`
- `objectstack-ai#2967/objectstack-ai#2904`
- `objectstack-ai#4648/objectstack-ai#4901`

This repository's records with the same numbers are unrelated. The other
3 (`cloud`, `hotcrm-heimao`) are boards one credential cannot read, so
they stay unjudged, as they were before.

## H2: where the URL spelling belongs

The extractor. At `3693a1b50`, 57 URL sites sit in the gate's
projection: 56 in package comments and 1 link on a release page. 4 of
them are dead. Only 2 URL sites in package sources are inside string
literals, both internal `note:` strings in
`packages/runtime/src/route-ledger.ts`.

`check:doc-authoring` asks a different question: may a runtime string
carry a tracker reference at all? It reads string literals, skills and
spec refusal messages. The two projections are disjoint, so adding the
URL to the extractor double-counts nothing there. Inside the extractor,
the one double-spelled site (`[objectstack-ai#15325](...objectstack-ai/issues/15325)` on
`v17/17-3.mdx`) counts once. `check-doc-authoring.mjs` is not touched.

## H3: open PRs' added lines

All 13 open PRs at 2026-09-30T23:2xZ: their heads were fetched into a
private ref namespace (deleted afterwards). For each, the BASE extractor
and this one were run over the lines it adds, against its merge base.
Result: 85 added-line citations under both extractors, 0 newly
extracted, 0 lost. No PR's verdict changes. Lines a PR does not add are
never judged, which is unchanged and pinned in the `diff-scope` battery.

## Census, before and after

The gate's own `--census --json`, once with the `3693a1b50` script and
once with this one, over the same tree:

| | judged | resolves | resolves as PR | cross-repo |
allocated-but-absent |
|---|---|---|---|---|---|
| before (frontier 20965) | 37,152 | 33,403 | 1,985 | 1,024 | 740 |
| after (frontier 20966) | 37,796 | 33,917 | 2,083 | 1,041 | 755 |

That is 644 more judged sites and 15 more dead ones, with 0 findings
lost. By arm: hyphen 1 dead, slash 10 dead, URL 4 dead.

Newly visible dead sites per lane. I rewrote none of them; they belong
to the lane cards:

- **objectstack-ai#20594 (`domain:cli`): 1.** `packages/rest/src/rest-server.ts:7456`,
objectstack-ai#11006.
- **objectstack-ai#20595 (`domain:engine`): 6.**
- `driver-sql`: `sql-driver.ts:3933` (URL, objectstack-ai#17590), `:12110` (objectstack-ai#10629),
`:16106` (objectstack-ai#17343).
  - `metadata`: `loaders/ambiguous-metadata-stem.ts:40` (objectstack-ai#14423).
- `metadata-protocol`: `migrations/partial-index-probe.ts:395` (objectstack-ai#16657).
  - `objectql`: `plugin.ts:1496` (objectstack-ai#10629).
- **objectstack-ai#20596 (`domain:services`): 0.**
- **objectstack-ai#20597 (`domain:spec`, `packages/lint`): 0.**
- **objectstack-ai#20234 (`packages/spec/src`): 7.**
  - `data/datasource.zod.ts:701` (objectstack-ai#9040).
  - `data/filter.zod.ts:1040` (URL, objectstack-ai#17590) and `:1042` (URL, objectstack-ai#17286).
  - `data/value-roundtrip-conformance.ts:100` (URL, objectstack-ai#12380).
  - `ui/component.zod.ts:593` and `:669` (objectstack-ai#6276), and `:3771` (objectstack-ai#9972).
- **Release pages (`domain:devx`, no lane card): 1.**
`content/docs/releases/v17/index.mdx:168` (objectstack-ai#6075).

This census was run on the PR head's tree, not after landing. A re-run
after landing reads the same corpus plus whatever `main` has gained by
then.

## In-place fixes beyond the three named rows

I retired the `acceptance` and section-mark rows here rather than filing
them. All four conditions hold:

1. They are the same defect class as `option` and `clause`: a head row
hiding a board citation.
2. The fix is mechanical, and the shape is pinned by the table.
3. The file is this claim's own surface (`NON_CITATION_HEADS`).
4. The same gate's self-test covers them, so no new verification surface
is added.

Evidence: `acceptance objectstack-ai#6132` (live) in
`packages/formula/src/cel-pushdown-limits.ts:82`, and the section-mark
sites in deferred test files. Neither row has any two-or-more-digit
ordinal anywhere in the repository.

## Gates (final head `25d96fcc0`)

I re-derived the list with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack`, which gave 32 commands.
The same derivation on a throwaway tree at `origin/main` (`05be35259`)
with this diff applied gave an identical list. I ran all 32, plus `pnpm
check:doc-authoring` and the self-test, and every one exited 0.
Reconciliation verdict line:

`✓ dispatch-gates --ran: 32 derived famil(ies) accounted for — 32 run, 0
NOT-MEASURED (a DERIVED zero — all 32 recorded an exit code and none of
them is 3).`

Verdict lines worth quoting:

- `node scripts/check-issue-citations.mjs --self-test`: `✅ ... every
spelling enumerated ... (173 cases, 9 batteries)`. It also passes on
`origin/main` `05be35259` with this diff applied.
- `node scripts/check-issue-citations.mjs` (diff mode): `✅
check-issue-citations: no issue citations added against 3693a1b (0
file(s) read).` The script lives in the deferred `scripts/**` surface.
- `pnpm check:pm-dispatch-gates`: `✓ dispatch-gates self-test: 1976
cases pass.` (1237.9s). Its pin `scripts/check-issue-citations.mjs:204
local-env` holds.
- `pnpm check:doc-authoring`: `✓ doc authoring guard: ... hold the
baseline — 549 pinned site(s)`.
- `pnpm check:nul-bytes`: `check-nul-bytes: OK (scanned 9582 text
file(s) ...)`.
- `node scripts/check-scripts-symbol-anchors.mjs`: `✅ ... 3706 anchors
across 282 scripts resolve`.

## Ablations (one-shot, from committed `25d96fcc0`, via
`scripts/ablation-replace.mjs`)

Every leg was expected to go red, and every leg did. Each one restored
to blob `c732ce2e21c7` (equal to HEAD) with an empty `git diff HEAD`. No
permanent ablation file is left.

| mutation | first red |
|---|---|
| hyphen refused again after the number | `the live corpus must yield a
#N-word citation` |
| `/` refused again before the `#` | `the live corpus must yield a
slash-joined #A/#B second number` |
| URL arm disabled | `the live corpus must yield a URL-spelled citation`
|
| `option` head row restored | `spelling option #N in "the option objectstack-ai#14088
gave" must read objectstack-ai#14088; got nothing` |
| continuation disabled | `spelling repo#A/#B ... must read
objectstack-ai/objectui#2711, objectstack-ai/objectui#2722` |
| `](` row disabled | `a markdown link's in-page heading anchor is not a
citation` |
| link de-duplication disabled | `spelling [#N](URL) ... must read
objectstack-ai#15325` |

The first slash ablation, run before the floors were tightened, went red
in the table but left the live-corpus continuation floor green. A line
citing the same number twice let a plain citation stand in for the
slash-joined one. Commit `25d96fcc0` makes each new floor count only a
number spelled once on its line. The re-run above is red at that floor.

## Acceptance notes

- **URL spelling in runtime strings.** `check:doc-authoring` does not
read it. At `3693a1b50` the population is 2 internal route-ledger
`note:` strings (`packages/runtime/src/route-ledger.ts:459`, `:465`),
and no author-facing door shows them. Noted, not filed; carrier: none.
- **Range second numbers.** In a range such as `objectstack-ai#712-714`, the second
number carries no `#` and is not read. There is 1 site repo-wide, in
`docs/audits/**`, outside the declared surfaces. Pinned in the table
with its reason.
- **Dormant test-file citations.** The 8 test-file citations the retired
`clause` and section-mark rows hid are in the deferred test surface.
They become visible only when that surface is swept.
- **Line-number pin.** `dispatch-gates.mjs` pins this file's `local-env`
marker by line number (`:204`), so any future header growth above it has
to move that pin in the same PR.

No changeset: a root `scripts/` file publishes nothing (the root
`package.json` is private, and no package's `files` ships `scripts/`),
so this PR takes `skip-changeset`.

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

---------

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 size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants