Skip to content

feat(scripts): refuse an undeclared mode-160000 gitlink in the index - #18414

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-17472-gitlink-declared
Sep 20, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-17472-gitlink-declared

Conversation

@os-try-charles

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

Copy link
Copy Markdown
Collaborator

Part of #17472.

Authored by the Claude Code session session_017ef78bLdybu3AffehKkhfk (https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk).

Nothing in this repository read index modes, so git add -A over a nested git repository — a linked worktree, a nested clone, a vendored checkout — staged exactly one entry at mode 160000 at exit 0 with only a warning: line, and every clone afterwards carried a submodule pointer to a commit that exists in no clone of this repository. This adds the gate that refuses it.

⛔ This is not the untracked-signal half; PR #17468 owns that one and closed it by ignoring .worktrees/. This is the stage, and it is path-blind on purpose: the class generalises past any path, so there is no path list here to fall out of date.

The shape chosen, and the ones rejected

scripts/check-gitlink-declared.mjs enumerates the index (git ls-files --stage -z) and refuses any entry at mode 160000 that no tracked .gitmodules row declares. Root package.json gains check:gitlink-declared in the house spelling (--self-test then the live run), and .github/workflows/lint.yml gains an unconditional step in Lint & Repo Gates, beside Raw control-byte guard — its structural sibling: whole-index population, a hygiene property of what a clone receives, and a defect whose only native signal is a warning on a command that exits 0.

Four decisions, each with the alternative it beat:

  1. "undeclared", not "no gitlinks at all". A flat ban is the stronger rule and was rejected: it bans the legitimate case along with the accident, and the two are told apart by a fact every repository with a real submodule already writes down. git submodule add writes the declaration and the pointer in one act, so a real submodule passes the day it is added — no exemption, no allowlist, no flag. It also makes the card's acceptance shape possible at all: a flat ban has no passing direction to test.
  2. The declaration is read out of the INDEX, via git config --blob :.gitmodules, not off the working tree. A .gitmodules present on disk and never staged would otherwise vouch for a gitlink — and that combination is the clone-side hazard: the clone receives the pointer and not the file that explains it. A declaration that does not travel with the commit declares nothing. The self-test pins this direction separately.
  3. Refusing from day one, with the report half as --list. The card suggested "report-only, then refusing". A staged rollout buys time to clear a backlog, and there is no backlog — the tree carries zero gitlinks — so a report-only phase would be a phase in which the gate refuses nothing and finds nothing, and the day it started refusing would be the first day it was ever exercised. --list prints every gitlink and how each is judged (including a declaration with no gitlink beside it, reported and never a finding), whether or not anything is a finding.
  4. No .githooks/pre-commit wiring. That is the earliest possible refusal point and it is outside the dispatched file surface (scripts/, root package.json, .github/workflows/lint.yml). The gate is written so the wiring is a one-liner if the maintainers want it: git() deliberately does not scrub the ambient git environment, so an inherited GIT_INDEX_FILE — the index a hook is being asked about — is the index it judges.

No new runtime dependency: git itself is the .gitmodules parser (it is git config syntax, and a second reader of line continuations, quoting and subsection escaping would be a second dialect to keep in step). The manual floor was not hit.

Self-test, both directions, as output

The gate ships a two-direction self-test over throwaway repositories under a temp dir — never inside this checkout — driven through the same scan() the live run calls.

$ node scripts/check-gitlink-declared.mjs --self-test
✓ check-gitlink-declared --self-test: 36 assertions over throwaway git repos (real scan() path)
  exit=0

Its forward fixture is the card's own measurement, reproduced with a literal git add -A rather than a hand-assembled index, and the fixture is asserted before the gate is (a run in which git add -A staged nothing would otherwise look exactly like a gate that works). The six batteries and their floors are declared in the script; the floor requires the set of batteries that registered assertions to equal the set declared, so a section that stops running names itself instead of going quiet.

The same two directions on the production path — the script's own main(), its failure text and its exit code — measured in a throwaway repo outside every checkout:

$ git add -A                          # the card's own measurement
warning: adding embedded git repository: vendor/thing
hint: You've added another git repository inside your current repository.
  exit=0
$ git ls-files --stage
100644 45b983be36b73c0788dc9cbcb76cbb80fc7bb057 0	readme.md
160000 4707cf6e9b8a1d7cda7df17d731cd4b7066b300d 0	vendor/thing

DIRECTION 2 -- a bare gitlink fails
$ node scripts/check-gitlink-declared.mjs
check-gitlink-declared: 1 index entry is a gitlink that .gitmodules does not declare

  • vendor/thing -- mode 160000, commit 4707cf6e

A mode-160000 index entry is a SUBMODULE POINTER. Staging a nested git
  ... (remedy text elided here; it is in the script)
  exit=1

DIRECTION 1 -- the same index, plus the row `git submodule add` writes
$ node scripts/check-gitlink-declared.mjs
check-gitlink-declared: OK (3 index entries -- 1 gitlink(s) at mode 160000; 1 submodule path(s) declared in .gitmodules; every gitlink is declared).
  exit=0

The nested repository sits at vendor/thing rather than under .worktrees/ deliberately: the ignore PR #17468 landed covers that one path, and this gate is about the class.

On this repository the live run is:

$ pnpm check:gitlink-declared
check-gitlink-declared: OK (8726 index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the index, so nothing is declared; nothing to declare).
  exit=0

The gitlink count is printed unconditionally, 0 included: a summary that named gitlinks only when it found some would make "there are none" and "I did not look" render identically.

The card's citation, corrected

The card body and its triage comment both state that 160000 "appears in the tree only as two skip comments in scripts/check-nul-bytes.mjs". Re-derived at 7358c1c5b with controls taken from the probed tree itself:

reading value
git grep 160000 across the tree 0 — the literal appears nowhere
firing control git grep -i gitlink 2 — scripts/check-nul-bytes.mjs:193, :521, both prose skip comments
firing control git grep -ic nul scripts/check-nul-bytes.mjs 76
dark control (a nonsense token) 0
git grep -i gitmodules 0, and no .gitmodules file exists
git ls-files --stage mode histogram 100644 × 8691, 100755 × 34 — no other mode

The substance holds and holds harder than the card claimed; the citation form does not. ⛔ 160000 cannot be used as a firing control when re-deriving that zero — it gives a double zero.

Changeset: skip-changeset, measured against files[]

The sole criterion is whether anything published moves, so this was measured rather than argued from the path names, over the 83 tracked manifests at 298e245de:

reading value
tracked package.json manifests 83
of those, published (not private: true) 70
published packages whose directory is the repo root 0
published packages with no files[] (i.e. shipping their whole directory) 0
published files[] entries escaping their own package directory (../ or a leading slash) 0
published files[] entries naming scripts at all 0
positive control — @objectstack/spec files[] dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json

The root manifest is @objectstack/spec-monorepo, private: true — it is never published. Every published package declares a files[] and none of them can reach a repo-root path, so none of this PR's three paths can be inside any tarball. ⇒ skip-changeset, applied on the PR.

Verification

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 63 families for this change set; the new gate discovers itself and is placed under the always-runs whole-tree heading with its liveness spelling vouched (a git ls-files enumeration of the tracked corpus).

All 63 ran, at bfa686a61 for the sweep and 298e245de for the two re-runs named below. 58 exit 0. The other five exit 3 — PREREQUISITE NOT MET, which is NOT MEASURED and neither a pass nor a finding: check:dts-closure, check:dual-build-cjs-loads, check:lean-entry-closure, check:sourcemap-no-sources-content and check:type-check-debt all read built package output, and no package was built in this worktree. They are derived only because the diff touches the root manifest; this diff adds no package source and moves no files[] content, so it cannot move them, and CI runs them after a build. That narrowing is declared here rather than smoothed over.

Beyond the derived set:

  • pnpm lint — the full repo-wide run, not a narrowed one, re-run on the final commit 298e245de: eslint . --no-inline-config --format json over 6790 files, 0 errors, 0 warnings, exit 0. The file count is read off eslint's own --format json output and the population is eslint's own config resolution, not a guess; this repo's single eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules) for any file, so nothing in this diff can move the verdict on a file it does not touch.
  • node scripts/check-ci-filter-parity.mjs --self-test — exit 0, 47 assertions.
  • node scripts/pr-labels.mjs --self-test — exit 0, VERDICT: pr-labels self-test PASSED.
  • pnpm check:nul-bytes, pnpm check:entry-guard, pnpm check:parse-guard — exit 0 (all three are inside the derived 63).
  • pnpm check:pm-dispatch-gates — exit 0, dispatch-gates self-test: 1730 cases pass. Run twice, once per commit; on the final commit 298e245de the battery took 753.4s on this box.

No verify lock was taken: nothing here builds or tests a package, so no command needed scripts/pm/os-verify-lock.sh. There is no VERDICT line to quote, and that is a fact about this diff rather than a step skipped.

Acceptance notes

  • The gate caught its own author, before it was even wired up. The first draft of this script declared its -z delimiter by writing the backslash-u escape for the NUL byte as a string literal. The editing tool materialised that escape into a raw NUL byte on disk, and the byte-discipline self-scan found it at line 113 — the accident source check-nul-bytes.mjs's header documents, landing on the very file being written about a git-plumbing delimiter, and a case that check:nul-bytes would have caught at push time had the self-scan not. Fixed the way that header prescribes: built from the byte value with String.fromCharCode(0), never written as a literal anywhere in the file.
  • A .gitmodules row with no gitlink beside it is the mirror defect (a declared submodule that is not in the index). It is reported by --list and deliberately not a finding: it is a different subject, and this gate refuses exactly one thing. Noted, not filed — no PR or person is heading for it, and nothing in the tree can produce one today.
  • .githooks/pre-commit is the earliest refusal point and is outside this PR's file surface; see decision 4 above for why the gate is nonetheless written to be wired there without a change.
  • summarise() counts index ENTRIES, so a conflicted index (the same path at stages 1, 2 and 3) inflates its gitlink count while the finding list stays one row per path. The number a reader acts on is the finding count, and findOffenders is pinned on that shape.

Generated by Claude Code

Nothing in this repository read index modes, so `git add -A` over a nested
git repository -- a linked worktree, a nested clone, a vendored checkout --
staged one mode-160000 entry at exit 0 with only a `warning:` line, and every
clone afterwards carried a submodule pointer to a commit that exists in no
clone of this repository.

`check:gitlink-declared` enumerates the index and refuses any entry at mode
160000 that no tracked `.gitmodules` row declares. A real submodule passes
untouched: `git submodule add` writes the pointer and the declaration in one
act. The declaration is read out of the index blob rather than the working
tree, because a `.gitmodules` that never gets staged does not reach the clone
that receives the pointer.

The self-test runs in both directions over throwaway repos: the card's own
measurement (a nested repo plus a literal `git add -A`) is refused and names
the path, and the same index with the declaration added is green.

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

Three follow-ups on the gate, none of them behavioural for a clean index:

- the dispatch-gates population marker is one line, because the marker is
  matched line-by-line and a wrapped reason printed to a seat cut off
  mid-sentence;
- `findOffenders` deduplicates by path, so a conflicted index -- which carries
  the same path at stages 1, 2 and 3 -- is one finding and not three;
- the header's zero-submodule reading now names the tree it was taken against.

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

Copy link
Copy Markdown
Collaborator Author

PM review — ACCEPT

Reviewed against GitHub and against a detached worktree at this PR's head 298e245de82dfb2059e647a6dfb37810b6db5f49, ⛔ not against the report's narrative. ⭐ The two-direction acceptance is the part of this card most easily faked, so I did not take the self-test's word for it — I built the fixtures myself, from the card's own measurement, in a throwaway repo outside every checkout.

The gate, exercised by me rather than by its own harness

nested repo at vendor/thing, then a literal `git add -A`
  ⇒ warning: adding embedded git repository: vendor/thing
  ⇒ git ls-files --stage:  160000 bb8cf93723e… 0  vendor/thing
direction what I ran exit what it said
2 · bare gitlink the index above 1 names vendor/thing -- mode 160000, commit bb8cf937, then the two remedies
1a · declared on DISK but NOT staged same index + an unstaged .gitmodules row 1 the finding stands
1b · the same row STAGED git add .gitmodules and nothing else 0 every gitlink is declared

⭐ 1a → 1b is the reading that makes this gate worth having, and it is the design decision I would have got wrong: only the index changed between them. The gate reads the declaration with git config --blob :.gitmodules (:193), ⛔ not from the working tree — because a .gitmodules that is never staged never reaches the clone that receives the pointer. A gate that read the worktree copy would go green on exactly the tree that ships the dangling pointer.

⚠️ One thing I have to say about my own instrument: my first attempt at direction 2 reported EXIT=1 — from ERR_MODULE_NOT_FOUND, because I had copied the script away from its invoked-as.mjs sibling. An exit code is not a verdict about the subject until you read what produced it. I re-ran it from the script's real path before believing the 1.

Also re-derived here, independently: --self-test exit 0, 36 assertions; the live run exit 0, 8726 index entries -- 0 gitlink(s) at mode 160000. :221 confirms main(), --list and --self-test all go through the one scan(), so the self-test exercises the production path rather than a parallel one.

The two shape deviations — both accepted

The card's suggested shape was report-only-then-refusing. This ships refusing from day one, with the report half as --list. The argument, which I checked: there are zero gitlinks in the tree, so a report-only phase would never once have exercised the refusal — the day it became a refusal would be the day it was first tested. ⇒ Accepted. ⛔ The triage comment made the shape a proposal twice over, so this is inside the fence, not a breach of it.

Fences, checked one by one

  • File surface: 3 files, exactly the three declared — scripts/check-gitlink-declared.mjs (+680), package.json (+1), .github/workflows/lint.yml (+22). ⛔ Zero breach.
  • Manual floor NOT hit: no new runtime dependency — git itself parses .gitmodules. This was the one floor named in the dispatch.
  • House shape: root key is node scripts/check-gitlink-declared.mjs --self-test && node scripts/check-gitlink-declared.mjs, character-for-character the sibling spelling; the lint.yml step is unconditional, inside Lint & Repo Gates.
  • ⛔ The untracked half was not re-closed — lint.yml:428-429 says so in its own comment, naming PR chore(repo): ignore .worktrees/ so an agent worktree inside the checkout is not untracked #17468.
  • Commit trailers, both commits: Co-authored-by: Claude <noreply@anthropic.com> + Claude-Session:, model-free, ⛔ no card trailer. ✅ No amend, no force-push.
  • check-clause2-carriers --pair 18414 ⇒ exit 0; Clause-②: no, both carriers agree, no widening tell.
  • skip-changeset, re-derived by me at this head and matching the report number for number: 83 tracked package.json, 70 published, 0 with no files[], 0 whose files[] names scripts at all, 0 escaping their own directory, root manifest @objectstack/spec-monorepo private: true. Positive control that the instrument reads real entries: @objectstack/spec's files[] = dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json. ⇒ zero published bytes move.

The declared narrowing, and why it stands

5 of the 63 derived families exited 3 — PREREQUISITE NOT MET, read as NOT MEASURED rather than as a pass: check:dts-closure, check:dual-build-cjs-loads, check:lean-entry-closure, check:sourcemap-no-sources-content, check:type-check-debt. All five read built package output that this worktree has none of. ⇒ The narrowing is declared rather than smoothed over, and it holds on the same reading that carried skip-changeset: this diff adds no package source and moves no files[] content, so it cannot move those five, and CI runs them after a build. ⚠️ ⛔ I am not treating an exit 3 as green — I am treating it as not a reading, which is what it is.

⭐ And the slow gate was not allowed to become a NOT MEASURED: check:pm-dispatch-gates ran bare, once per commit (775.1 s, then 753.4 s), each time blocked on with tail --pid per the prescription at platform-readings.md:425, and passed 1730 cases. That is the failure this seat paid for on #16421.

Two things the dev handed me, and what I did with each

  1. ⭐ A real finding, verified at source before I filed it. WHOLE_TREE_POPULATION_MARKER (dispatch-gates.mjs:2832-2834) captures (\S.*)$ under the m flag ⇒ the reason ends at the first newline; NO_PATH_POPULATION_MARKER (:2754-2755) is identical; and wholeTreePopulationRefusal (:3037-3066) checks only that a reason exists, that no second population marker contradicts it, and that a root walk backs it — ⛔ never that the reason is whole. A wrapped reason therefore reaches a seat as a sentence that simply stops, with nothing red. Filed as [finding] dispatch-gates' population markers capture only the FIRST line of a declaration's reason, and no refusal checks the reason is whole — a wrapped reason reaches a seat cut off mid-sentence, with nothing red #18422. ⛔ I read all three sites myself rather than transcribing the frame; this shift has already produced three dev frames that would have sent the next person to the wrong file.

  2. A correction that is mine. My dispatch template transcribed a commit-trailer literal carrying a model identifier, which this repo's pre-push gate refuses (scripts/check-commit-card-trailers.mjs:300 declares the pair; :876/:931 assert that exact string is the offending one). This dev had already resolved it the same way before its first commit and ⛔ declined the OS_ALLOW_CARD_TRAILER_PUSH=1 override — both right, and it cost this run zero cycles. ⭐ Its recommendation is better than my fix: cite the rule rather than transcribe the literal, because a literal restated in two places is exactly the drift this repo gates elsewhere. Adopted.

Landing

⛔ This PR is human-merge only and I am not arming it: the diff touches .github/workflows/**, on which this seat's auto_merge is permanently 422. That was declared in the claim before dispatch, not discovered here. Lint & Repo Gates is still in_progress on this head; when the full check set is green by name-deduped reading I will mark it ready and request review, ⛔ not arm it.

PM seat domain:devx · round 5 · reviewed head 298e245de82dfb2059e647a6dfb37810b6db5f49 · 2026-09-16T10:17Z


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Ready for human merge — ⛔ this seat cannot arm it

@hotlong — review requested on GitHub, and the request is read back from the API rather than merely stated here (requested_reviewers: ['hotlong']).

Why you and not the queue: the diff touches .github/workflows/lint.yml, and this seat's auto_merge is permanently HTTP 422 on a workflow-touching PR (the token carries no workflows scope). ⛔ That is a real GitHub-side refusal, not a session-classifier limit, and ⛔ it is not retried. It was declared in the claim on #17472 before dispatch, not discovered at review time.

State, all three read after the ready flip — marking ready re-triggers Governed Surface Queue Guard, so the green predicate was re-taken afterwards rather than reused:

checks     53 raw → 34 deduped by check name → NOT-GREEN 0
draft      false
mergeable  clean

What it is: a new check:gitlink-declared family that refuses any index entry at mode 160000 which no tracked .gitmodules row declares. 3 files, +703 −0, skip-changeset (measured: 83 tracked manifests, 70 published, 0 whose files[] names scripts at all).

⭐ The one reading worth your time before merging. The gate reads the declaration from the index blob (git config --blob :.gitmodules), ⛔ not from the working tree. I built the fixtures myself rather than trusting the self-test, and that decision is what the gate turns on:

fixture exit
nested repo + literal git add -A ⇒ a bare 160000 row 1, naming the path
the same index + a .gitmodules row on disk but unstaged 1 — the finding stands
the same row staged, and nothing else changed 0

A gate that read the worktree copy would go green on exactly the tree that ships the dangling pointer.

Full review with every re-derived number: comment 5695851514.

PM seat domain:devx · round 5 · head 298e245de82dfb2059e647a6dfb37810b6db5f49 · 2026-09-16T10:23Z


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

两条巡查行的答复,连同它们各自要求的读数

本轮 origin/main 上的半状态巡查在本 PR 上报了两行。两行都在这里就地答复,⛔ 不留给读者去猜。

H36 —— 与 PR #18503 共享 .github/workflows/lint.yml,巡查给了处方,我跑了

巡查行的原话:「Whichever lands second re-probes before (re-)arming: fetch the PR ref and run git merge-tree --write-tree against current main — the zero-quota reading GitHub never volunteers」。

照跑,2026-09-16T17:16Z:

main      8cf527f8e0
#18414    298e245de8
#18503    016bdeaa43

git merge-tree --write-tree  main   #18414   ⇒ exit 0   tree 5667b1994f
git merge-tree --write-tree  main   #18503   ⇒ exit 0   tree 19e5fc73d9
git merge-tree --write-tree  #18414 #18503   ⇒ exit 0   tree 45a47cbc59   ← 直接对撞,也干净

⇒ 三种配对全部 exit 0,无冲突。 而且两者在同一文件上的方向是错开的:

PR lint.yml 的改动
#18414(本 PR) 1 file changed, **22 insertions(+)** — 新增一个 step
#18503 1 file changed, **32 deletions(-)** — 移除若干 step

⚠️ 说明读数的限度:git merge-tree A B 是用两者的 merge base 做的真三方合并,⛔ 不是"先把 #18503 并进 main 再并本 PR"的逐步模拟。但三种配对都干净,且 merge-tree 本就是三方合并,所以「谁先落地都不会撞」这个结论在文本层面成立。⛔ 它不保证语义层面:若 #18503 移除的 step 正是本 PR 新增 step 所依赖的东西,那是语义冲突,merge-tree 看不见。⇒ 谁后落地,重跑一次门禁而不是只看这个 exit 0。

H12 —— 「orphan landing … auto-merge unarmed」:⛔ 不是孤儿,是按设计不可武装

巡查行给了三个处置(re-queue / fix / park as draft with a stated reason)。⚠️ 本 PR 是第四种,行里没有枚举到,所以在这里写清楚:

本席武装不了它,而且这不是失误。 它的 diff 触 .github/workflows/**,本席的 token 缺 workflows scope ⇒ auto_merge 恒 HTTP 422。⛔ 这是 GitHub 侧的真 422,不是会话分类器的限制,⛔ 也不重试。这一条在卡 #17472 的认领评论里派发前就写明了,不是复核时才发现的。

⇒ 三个选项逐一不适用:

  • ⛔ re-queue —— 进不去,422;
  • ⛔ fix —— 没有东西要修:本 head 298e245de8 上 53 raw → 34 去重 → NOT-GREEN **0**,mergeable_state: **clean**;
  • ⛔ park as draft —— 转回 draft 会把它从人工合并的视野里拿掉,降低它落地的概率,与本行的意图相反。

实际状态:绿 · clean · ready · requested_reviewers: ['hotlong'](已从 API 回读,⛔ 不是"报告里点名")· reviews: NONE(尚无人提交复审)。⇒ 只差一次人工合并。

PM seat domain:devx · session session_017ef78bLdybu3AffehKkhfk · round 11 · ⛔ 未重试武装,未改状态 · 2026-09-16T17:17Z


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 36b20ca Sep 20, 2026
54 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-17472-gitlink-declared branch September 20, 2026 13:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…on is red (objectstack-ai#18660)

Fixes objectstack-ai#18422

## The defect

The three population markers in `scripts/pm/dispatch-gates.mjs` —
`NO_PATH_POPULATION_MARKER`, `WHOLE_TREE_POPULATION_MARKER` and
`WIDE_POPULATION_MARKER` — each captured their reason with `(\S.*)$`
under the `m` flag, so the capture ends at the **first newline**; a
reason an author wraps across two or three comment lines was captured as
line one only. Nothing refused it: read line by line,
`wholeTreePopulationRefusal` checks that a reason EXISTS, that no
sibling marker contradicts it, and that a root walk BACKS it — never
that it is whole, and there was no other caller that did. The row
rendered as a sentence that merely stops, with nothing red at author
time or at read time, and the reason is the one thing a seat reads off
that row when deciding whether a family belongs on its card. Measured on
objectstack-ai#17472 / PR objectstack-ai#18414; measured again live in this tree, below.

All three markers shared the shape (`WIDE_POPULATION_MARKER` included —
measured, not assumed), so the contract lands on all three.

## Contract, before and after

| | before | after |
| --- | --- | --- |
| capture | `(\S.*)$` under `m` — ends at the first newline | unchanged,
and **pinned as the defect** |
| a wrapped reason | reaches the seat as line one | **REFUSED**, naming
the declaration, the file and the line that continues it |
| what terminates a declaration | nothing — the capture just stopped | a
blank line, a blank comment line, a non-comment line, EOF, or another
`dispatch-gates:` declaration |
| consumers of the reason | the fragment, silently | the whole reason,
or a refusal — `alwaysRunsPopulationLines` and the `--json` `refused`
field for whole-tree and wide, the undetermined listing for no-path |

The grammar now has ONE spelling (`populationMarkerPattern`), because
the continuation reading has to agree with the capture about what a
marker line is, down to the comment form; two spellings of one grammar
drift silently. `populationReasonContinuation` reads, off the same text,
the comment line that continues a reason; `readPopulationDeclaration`
records the reason and its continuation from ONE source and ONE file, so
a refusal can never grade file A's reason against file B's continuation;
`populationReasonCutRefusal` is the refusal, shared by all three
channels, and the whole-tree and wide refusals delegate to it.

Ordering, deliberate and pinned both ways: the cut is refused **after**
the two-marker pair refusals (a declaration that contradicts a sibling
is refused for THAT, in the words a reader has been getting for it) and
**before** the whole-tree walk check and the wide hint check — both of
those grade the declaration against a reason this reading says is only
part of one, so a hint named in the wrapped half would read as
unaccounted for and the refusal would name the wrong defect in confident
words.

## Shape B, and why — the four axes

**Shape B (refuse at author time)** over shape A (consume a comment
block).

- **实际业务需求** — measured over the tree at this head: 27 declarations
across 25 gate files, **26 already writing the whole reason on one
line** (up to 1215 characters of it, `check:route-envelope`), and
exactly **one** wrapped and being cut today. The one-line reason is what
this convention already IS; the pull is for the one declaration to be
made whole, not for a multi-line grammar nobody in this tree writes.
- **项目长远合理性** — B is contract-first: a declaration has ONE spelling and
the refusal states it. A adds a second grammar whose central question —
is this following comment a continuation, or the next paragraph —
nothing in the text can answer. This file refuses coin tosses everywhere
else ("a derivation that picked either would be placing the family by a
coin toss"); A is that coin toss, one channel further along.
- **防 AI 写错元数据** — B is 契约收紧 with a loud refusal at author time naming
the file and the line. A is 消费端宽容: it would silently make an unrelated
comment part of a seat-facing reason — the same class of defect this
card was filed on, pointed the other way, and unfalsifiable because
there would be nothing to be red.
- **创业阶段不扩散** — B is the smaller change and stays inside the refusals
that already exist; A changes what a marker IS and grows the grammar for
a spelling with no caller.

No axis conflicts, so there is no trade-off to hand up. The triage
boundary (5713131801) is held: this is the capture domain and its
validation, ⛔ not a marker that matches arbitrary multi-line text.

## The pins

The file's `--self-test` registers cases with `t(name, cond, detail)`
into one flat `cases` roster and holds the `SELF_TEST_VERDICT` handshake
the dispatch refuses without; per
`docs/audits/2026-09-self-test-shape-census.md:377` its floor is `NONE`
and its handshake is `HELD` — a recorded shape, unchanged here.

**Registered: 25 new cases** (fixture battery `A declaration's reason is
WHOLE, or the declaration is RED (objectstack-ai#18422)`, plus three live-half pairs).
**Whole file after: `✓ dispatch-gates self-test: 1771 cases pass.`**

What they pin, clause by clause:

- the **objectstack-ai#17472 first-draft shape** as a case — the capture still ends at
the first newline (pinned as the defect, not as a claim it went away),
and the wholeness reading names the continuation line;
- **a one-line reason ⇒ unchanged** — not continued by the code under
it, nor by a blank line, a blank comment line, or EOF;
- **a following comment line that starts a NEW `dispatch-gates:` key ⇒
not a continuation** (it is a second declaration; the pair refusals
grade that shape);
- the comment **form** must match — a `#` line under a `//` declaration
is not a comment in that language;
- the **shell spelling** read the same way, continuation and all;
- **a cut reason is REFUSED on each of the three channels**, and the
refusal names the declaration, the file and the line; a whole reason is
refused nothing on any of them;
- **the two-marker pair refusals are unchanged** by a cut reason — all
three pairs still refuse as the contradiction they are;
- the cut is named **before** the walk and **before** the hints;
- the always-runs renderer prints a cut declaration as a **REFUSED row**
rather than as the fragment it was cut down to;
- the discovery keeps the FIRST declaration a family meets and the cut
travels with it **from the same file**;
- an unknown marker key is refused by both readings, and the field
roster and the marker roster name the same three channels — neither can
grow one alone.

## The live derivation, over the tree at this head

Every declaration the three markers match today, run through the new
contract:

```
LIVE DERIVATION: declarations=27 refused=1
RED check:objectui-bump [no-path-population] files=scripts/bump-objectui.selftest.sh
    declares no-path-population and its reason does not END on the marker line:
    scripts/bump-objectui.selftest.sh:51 continues it with "lives inside a disposable
    checkout under mktemp -d (a throwaway objectui and". …
```

**One declaration reds** — and it was being cut on `main` right now, not
hypothetically: `check:objectui-bump` reached the seat as `every path
this file writes or reads`, 36 characters of a 466-character reason
wrapped over six comment lines. A refusal cannot land red on `main`, so
the declaration is made whole on ONE line in this same PR:

- `scripts/bump-objectui.selftest.sh:50` — comment-only edit, the
author's words joined verbatim at the wrap points (the join is asserted
byte-for-byte against the original lines, not retyped). `pnpm
check:objectui-bump` exit 0 after it.

After the repair: **27 declarations, 0 refused.** The count is not
vacuous — the refusal fired on this tree before the repair (above), and
three live-half cases in the self-test re-fire it per channel by putting
a continuation on a live entry and asserting the refusal names it.

## Ablation, from the committed fix

Mutation: the refusal deleted — `populationReasonCutRefusal` returns
null once it has a declaration, which is the pre-fix state exactly (the
cut is detectable and nothing is red).

```
HEAD blob: 75fe87e
injected marker count: 1
mutated blob: f3f37ce1cce932dc73b4af5991d093a6063fcdcc
ABLATION RUN EXIT: 1
✗ dispatch-gates self-test: 8 of 1771 case(s) failed.   (633.3s under the shared lock)
restored blob: 75fe87e
RESTORE VERIFIED: blob == HEAD blob, git diff HEAD empty
```

Predicted direction before the run: **转红**. Observed: **转红, 8 cases**,
and they are exactly the ones the refusal buys —

```
✗ a cut WHOLE-TREE reason is refused, and the refusal names the declaration, the file and the line that continues it
✗ a cut WIDE reason is refused through the same shared reading, naming its own channel
✗ and a cut NO-PATH reason is refused too — the channel with no refusal function of its own is not the channel without the contract
✗ but a cut reason IS named before the walk that would back it and before the hints it would be graded against
✗ the always-runs renderer prints a cut declaration as a REFUSED row rather than as the fragment it was cut down to
✗ and that reading is not vacuous over this tree: a live entry reds the moment a continuation is put on it            (no-path)
✗ and not vacuously: a live whole-tree entry reds the moment a continuation is put on it
✗ and not vacuously: a live wide-population entry reds the moment a continuation is put on it
```

The controls stayed GREEN under the same mutation, which is what makes
these an instrument rather than a restatement: the two-marker pair
refusals, `a WHOLE reason is refused nothing on any of the three`, every
continuation-reading case (the detector still works — it is the refusal
that was deleted, which is the card's whole point: *the cut is knowable
and nothing is red*), and the three `every live … reason ENDS on its own
marker line` cases (0 cuts in the tree either way).

Run under `trap '<restore>' EXIT INT TERM` with an absolute `REPO_ROOT`,
restored with `git checkout HEAD -- <path>` (never the bare form, which
takes the mutation back out of the index), and the restore proven by
blob hash AND by an empty `git diff HEAD` — not by an exit code.

## Gates

Derived from the tree with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no hand-fed path list;
the tool takes its own change set off the merge base), every command
run, each exit code captured redirect-then-`$?`, reconciled with
`--ran`.

**30 derived, 30 run, 0 NOT-MEASURED, 0 UNRUN — every one exit 0.**

```
0  node scripts/check-ci-filter-parity.mjs                      0  pnpm check:agent-test-spelling
0  node scripts/check-closing-keyword-parity.mjs                0  pnpm check:bash32-floor
0  node scripts/check-closing-keyword-parity.mjs --self-test    0  pnpm check:cli-command-ids
0  node scripts/check-comment-mask-corpus.mjs                   0  pnpm check:cross-package-test-inputs
0  node scripts/check-declaration-mirrors.mjs                   0  pnpm check:declared-population-live
0  node scripts/check-declaration-mirrors.mjs --self-test       0  pnpm check:driver-memory-census
0  node scripts/check-scripts-symbol-anchors.mjs                0  pnpm check:entry-guard
0  node scripts/check-scripts-symbol-anchors.mjs --self-test    0  pnpm check:nul-bytes
0  node scripts/check-self-test-wired.mjs                       0  pnpm check:objectui-bump
0  node scripts/check-self-test-wired.mjs --self-test           0  pnpm check:parse-guard
0  node scripts/check-self-test-workflow-commands.mjs           0  pnpm check:pm-dispatch-gates
0  node scripts/check-self-test-workflow-commands.mjs --self-test  0  pnpm check:pnpm-filter-targets
0  node scripts/check-whole-set-label-write.mjs                 0  pnpm check:ratchet-remedy-authority
0  node scripts/check-whole-set-label-write.mjs --self-test     0  pnpm check:refd-timer-probe
0  node scripts/pm/bare-root-worklist.mjs --self-test           0  pnpm check:watch-hint-literal
```

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

`pnpm check:pm-dispatch-gates` is this file's own `--self-test` and is
HEAVY: `✓ dispatch-gates self-test: 1771 cases pass.`, 633.9s, run
DETACHED per this file's own header (objectstack-ai#14281) and under
`scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0 · held the lock
635s · waited 0s`). It exceeds the ~10-minute foreground cap, which is
why the header says to detach it.

`origin/main` moved three commits under this branch mid-run, two of them
touching gate scripts, so the derivation was re-run from a scratch
worktree at `origin/main` `84ad2e139` against these two paths: the
family list came back **byte-identical** to the one above — no family
was added by the drift.

**`pnpm lint` repo-wide, not narrowed: exit 0 in 84s** (`node
--stack-size=4000 eslint . --no-inline-config`, at `3f4bf6b01`). The
narrowing this lane has been using was not needed on this run.

`skip-changeset`: `scripts/pm/**` and
`scripts/bump-objectui.selftest.sh` are in no package's `files[]` —
nothing published moves.


---

## Out-of-scope findings (⛔ not fixed here)

Both are the same CLASS as this card — an author writes a declaration
and the consumer silently drops part or all of it — and both are OUTSIDE
the file surface triage drew (5713131801), so they are named here for
the seat rather than repaired in this PR.

1. **To file** — the marker's comment-form alternation is `(?:\/\/|#)`,
so a declaration written inside a BLOCK comment parses as **nothing at
all**. Two live specimens: `scripts/symbol-anchors.mjs:180` (`/*
dispatch-gates: no-path-population -- …`) and
`scripts/release-verify-npm.mjs:110` (` * dispatch-gates:
no-path-population -- …`). Measured: `declaredNoPathPopulation` returns
`null` for both, both families sit in `undetermined` with `hints=0`, and
their authors' examined-and-explained status is dropped with no tell —
the residue counts them with the families nobody has looked at, which is
the exact bucket the marker exists to split. Dedupe words: `block
comment marker`, `no-path-population unparsed`, `symbol-anchors
declaration`, `release-verify-npm population`, `comment form
alternation`.
2. **To file** — the same first-newline capture is carried by the two
markers OUTSIDE this card's three: `NO_CHECK_FAMILIES_MARKER`
(workflow-level, read by `declaredNoCheckFamiliesReason`) and
`INHERITED_POPULATION_MARKER` (module-level, its `reason` half). Neither
is cut in the tree today (measured: the three live `no-check-families`
declarations and both `inherited-population` declarations are one-liners
followed by a blank line), so this is exposure, not a live defect. The
repair is one line each: register the key in `POPULATION_MARKER_KEYS` /
`POPULATION_DECLARATION_FIELDS` and read the continuation — the helper
this PR adds is generic and was deliberately built so the class closes
in one move. Dedupe words: `no-check-families reason cut`,
`inherited-population reason`, `population marker wholeness`, `first
newline capture`, `dispatch-gates marker roster`.

## Acceptance notes

- `noted, not filed`: this file's `--self-test` has no per-battery floor
roster (AGENTS.md 「Writing a `--self-test`」 asks for battery name →
minimum case count); it registers into one flat `cases` roster. Already
recorded — `docs/audits/2026-09-self-test-shape-census.md:377` grades it
floor `NONE`, handshake `HELD`. Not a finding, a censused state. 承接者:
whoever works that census row.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu

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

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

Fixes objectstack-ai#18680

Clause-②: no

## The defect

`CLAIM_COMMENT_MARKER` and `RELEASE_COMMENT_MARKER` anchor the bare word
at line start and tolerate only a leading blockquote, so a line written
in the decorated spelling a seat uses when it bolds the directive —
`**Release:** …` — begins with an asterisk and the record reads as
ABSENT. Nothing goes red; the ownership rows (H2, H47, H66, H67) simply
read a different history than the thread carries, which is the silent
direction. objectstack-ai#10102 made exactly this judgement for the other directive
family (`Blocked-by:` / `Restart-when:`) and never for these two
markers; PR objectstack-ai#18678 landed H67 declaring the loss on its own row rather
than widening, because widening moves three landed rows' populations and
is its own card. This is that card.

## Before-readings (reproduced offline, on the fetched specimen)

The live specimen is objectstack#16529 comment **5691473966**
(os-try-charles, 2026-09-16T03:11:44Z), fetched through the REST proxy
and never retyped. ⚠️ One correction to the card's prose: the
`**Release:**` line is the **45th** line of that comment, not its first
— both markers are `m`-flagged and scan every line, so line position was
never what hid the record. The asterisks were.

| probe | `RELEASE_COMMENT_MARKER` (bare) |
|:--|:--|
| the specimen's whole comment body | `false` |
| the specimen's release line alone | `false` |
| the SAME line with `**Release:**` replaced by `Release:` (control) |
`true` |
| `**Claim:** …` / `` `Release:` … `` / `__Release:__ …` / `## Release:
…` / `- Release: …` | all `false` |

`undecorateProseLine` — the objectstack-ai#10102 function — was measured rather than
assumed: its body is a single `replace` over a character class holding
exactly a backtick and an asterisk. It removes backticks and asterisks
and **nothing else** — not underscores, not a heading hash. That
measurement is what decides which spellings this repair reaches and
which become near misses.

Consumers of the two exported constants, all found by grep across
`scripts/`: nine call sites inside `check-half-states.mjs` itself (H2,
`governingClaim`, `latestClaimComment`, H34's guard, `h44ArtefactShape`,
`h46ClaimNamesBranch`, `latestMarkedComment` — which is H47's, H49's,
H50's, H53's and H67's shared resolver — `SEAT_SIGNATURE_FORMS`, and
`h66ReleaseVerdict`), each fed a raw comment body or one raw line; and
one cross-file importer, `scripts/pm/check-clause2-carriers.mjs`, which
feeds `CLAIM_COMMENT_MARKER` a raw comment body. That importer is
**outside this card's file surface and is deliberately unaffected**: the
constants keep their bare semantics.

## The ONE place decoration is handled

`markerMatches(marker, text)`, declared beside the two markers. It tries
the bare reading FIRST and short-circuits, then re-tests against the
same `undecorateProseLine` the `Blocked-by:` family uses. All nine
in-file call sites now go through it; ⛔ no regex was widened, ⛔ no
second stripper exists, and the two constants still describe the bare
directive (the cases that pin them still assert on them directly, and
they stay green).

Two properties fall out:

- **Strictly additive, by construction rather than by inspection.** The
bare test short-circuits, so ⛔ no body that read before can stop
reading. Measured on 770 live comments below: 0 regressions.
- **It refuses to undecorate through a markdown LIST ITEM.** The shared
stripper takes every asterisk, so a `* Claim:` bullet would become a
directive while H20's pinned `- Claim:` stays refused. A list marker is
followed by whitespace and a decoration is not; that is the whole
discriminator, and it is pinned both ways.

## The near-miss vocabulary — a line that looks like a marker makes a
sound

Widening alone leaves the same silence one decoration further out, which
is the triage's second half (comment 5716952460).
`OWNERSHIP_MARKER_NEAR_MISS_FORMS` is a frozen, named roster in the
register of objectstack-ai#18560's `SCHEMA_PROPERTY_FORMS`; each member carries its
own `example` fixture, and the roster is asserted EQUAL to a frozen list
of ids, so a form added without a fixture reds and a form silently
dropped reds.

| id | what | example |
|:--|:--|:--|
| `heading` | the directive written as a markdown heading | `## Release:
…` |
| `list-item` | the directive written as a markdown list item | `-
Release: …` |
| `underscore-emphasis` | emphasised with underscores, which the shared
stripper does not remove | `__Release:__ …` |
| `inflected-word` | a spelling the marker's vocabulary does not carry |
`Released: …` |
| `separator` | the canonical word with a separator that is not the
canonical colon | `Release — …` |

`ownershipMarkerNearMisses(commentRows)` is the reader; it buys nothing
(the sweep hands it threads other rows already paid for), files no
finding and proposes no state. It reports on an **unconditional summary
clause** (`Ownership-marker near misses: …`) naming the card, the
comment id and the offending prefix, capped at five named entries with
the remainder counted. A line the reading DOES read is ⛔ never a near
miss — the two are complements by construction, so a future widening
shrinks this census automatically.

H67's declared loss is retired in the same edit, on its row and in its
summary clause: both said the decorated line was invisible, and that is
no longer true.

## Pins

New battery **`H2/H47/H66 decorated ownership marker`**, 98 cases,
pinned at 94. The roster floor rose 4 to 5.

- the fetched objectstack-ai#16529 specimen: refused by the bare marker, READ through
`markerMatches`; the derived bare spelling of the same line matches both
ways (control); a release is still not a claim; the record reads from
any line of the body
- the rows: H2 goes clean on a decorated claim (with the no-claim
control still firing); `latestMarkedComment` locates a decorated
release; H66 reads it on the canonical leg with destination `pm:queue`,
quoted undecorated
- what the stripper measures: backticked and bold-italic directives
read; `__Release:__` does NOT, and is a near miss instead
- the firing controls of a widening, inside the same battery: seven bare
spellings still read, prose containing the word still does not,
`Released:` is still MALFORMED, the fullwidth colon still does not match
(the 2026-08-11 ruling is untouched), a dash-written claim is still
H34's row
- the bullet guard: an asterisk bullet, a hyphen bullet, an ordered `1.`
marker and a blockquoted bullet all refused; the whitespace
discriminator pinned as a pair
- the vocabulary: every member driven against its own fixture — not read
by either marker, reports naming its own form, with the comment id and
the offending prefix — plus the counterfactual roster-equality pin,
frozen-ness, distinct ids, and ⛔ no `g` and ⛔ no `m` flag
- the summary clause: counts, named entries, the cap clause,
unconditional rendering, render order, the forwarding contract, and ⛔ no
`undefined`
- **the PR objectstack-ai#18678 pin, EDITED and not deleted**: `H67 ⚠️ loss: a
DECORATED **Release:** line does not stand the row down` becomes `H67 ⚠️
loss CLOSED: … now STANDS THE ROW DOWN, as the bare one always did`, and
its companion flips from "the row DECLARES that blind spot" to "the row
no longer DECLARES a blind spot it no longer has". Its two control cases
are untouched and still green, because they assert on the marker
CONSTANT — which this PR does not change.

Self-test: **4782 cases / 4 batteries becomes 4881 cases / 5 batteries**
(98 battery cases, plus one case the per-anchor summary-clause coverage
loop registers for the new clause automatically).

## Ablation

Revert the one call that routes the markers through the undecorated line
(delete the undecorated leg of `markerMatches`, leaving the bare test
alone), from the committed state, with an EXIT/INT/TERM trap restoring
by absolute path.

- on-disk proof before reading any result: injected marker present 1
time, deleted text `const undecorated = raw` present 0 times, HEAD blob
`44aed794…` vs mutated blob `b4ab48a2…` (different, so ⛔ not a no-op)
- result: **17 red, 4864 green**. Every red is a case about the new
reading — the four objectstack-ai#16529 cases, H2/H47/H66 on a decorated record, the
five decoration cases, the two bullet-discriminator cases, and the
flipped H67 pin. Every bare-spelling control, every near-miss vocabulary
case, the `Released:` / fullwidth / prose negatives and the
marker-constant pins stayed green.
- restore verified by hash (`44aed794…` again) AND by an empty `git diff
HEAD` — ⛔ not by an exit code

## Live-board delta — report-only, ⛔ no state write of any kind

Two full sweeps, `node scripts/pm/check-half-states.mjs` against
`objectstack-ai/objectstack`: BEFORE on a detached worktree at
`62bce5c29` (17:53Z to 18:01Z), AFTER on this branch (18:01Z to 18:09Z).

**H2 / H47 / H66 verdicts: identical.** H2 fired on objectstack-ai#13597 and objectstack-ai#15638 in
both; H47 and H66 listed nothing in either. Eight rows differ between
the two runs (H14 objectstack-ai#18617, H38 objectstack-ai#7623, H52 objectstack-ai#18617 dropped; H1 objectstack-ai#18709, H19
objectstack-ai#18734, H36 objectstack-ai#18414/objectstack-ai#18720/objectstack-ai#18741 appeared) and every one is board churn
in the eight minutes between them — none reads an ownership marker.

**objectstack-ai#16529 specifically** still lists on H67 in both, and the reason has
nothing to do with the marker: its newest merge is now PR objectstack-ai#18678 (merged
2026-09-17), which is NEWER than the 2026-09-16 release record, so
"nobody has looked since the delivery landed" is a correct reading. Its
row text did change — the declared loss is gone.

Because a sweep only judges threads it bought, the zero above
understates the reading. So the same question was asked directly, over
the 286 open `pm:queue` / `pm:dispatched` cards and their 770 comments:

- **NEWLY READ as an ownership record: 4, on 4 cards; REGRESSIONS: 0.**
- objectstack-ai#16529 comment 5691473966 — `**Release:** …` (the card's own specimen)
  - objectstack-ai#17852 comment 5700605769 — a backticked `Release:`
  - objectstack-ai#15468 comment 5549954050 — `**Claim:** …`
  - #14026 comment 5486688759 — a backticked `Claim:`
- **Near misses over the same population: 2** — objectstack-ai#16233 comment
5704218834 and objectstack-ai#18143 comment 5707808263, both `### Release:` (form
`heading`). Over the sweep's wider 187-thread corpus the clause named
three: objectstack-ai#18336 comment 5693290763 (`list-item`), objectstack-ai#15768 comment
5556889538 (`heading`), objectstack-ai#6736 comment 5235658231 (`separator`).

And the counterfactual the rows themselves cannot show, offering the
SAME live thread to both readings — **3 of 6 cards change**:

| card | H47 | H66 |
|:--|:--|:--|
| objectstack-ai#16529 | FIRES becomes **clean** (the release now answers the claim) |
none becomes **`pm:queue`** |
| objectstack-ai#17852 | FIRES becomes **clean** | none becomes **the maintainer** |
| objectstack-ai#15468 | clean becomes **FIRES** (a bolded claim nobody has answered)
| unchanged |
| #14026, objectstack-ai#16233, objectstack-ai#18143 | unchanged | unchanged |

⛔ Nothing was written to any card, PR or label from either sweep, and ⛔
no verdict here is a proposal about any of those cards.

## Gates

Derived from this worktree with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (⛔ no hand-fed path list;
change set: `scripts/pm/check-half-states.mjs`, one path). 38 families,
every one run, exit code captured by redirect-then-`$?` before any pipe,
reconciled with `--ran`.

**37 of 38 exited 0**, with the two qualifications named below. Notably:
`check:pm-half-states` 0 · `check:nul-bytes` 0 ·
`check:closing-target-claim` 0 · `check:commit-card-trailers` 0 ·
`check:self-test-wired` 0 · `check:scripts-symbol-anchors` 0 ·
`check:declaration-mirrors` 0 · `check:whole-set-label-write` 0 ·
`check:changeset-no-major` 0 · `check:pm-governed-queue-guard` 0 ·
`check:cross-package-test-inputs` 0 · `check:parse-guard` 0.

⚠️ `pnpm check:nul-bytes` exited **1 on the first pass** and was right
to: an editing tool had materialised a backslash-u-0001 escape (written
out in words here for the same reason) into a real 0x01 byte in the
near-miss dedupe key — the exact slip that gate exists for. Fixed by
writing the escape text (byte-identical at runtime), re-run green, and
the rule's own `grep -naP` control-byte self-scan over the file returns
nothing.

⚠️ `pnpm check:pm-dispatch-gates` is the one family whose self-test runs
longer than this container's foreground ceiling: a first attempt reached
1768 green cases and was killed by the timeout wrapper at 560s (exit 124
= no verdict reached, which is NOT MEASURED and ⛔ not a red). It was
re-run detached; its verdict is reported in this card's `os-dev-report`
comment rather than guessed here. ⛔ Its diff-relevant half is unaffected
either way — this PR touches neither `dispatch-gates.mjs` nor its
fixtures.

Repo-wide `pnpm lint` (`eslint . --no-inline-config`): **exit 0**, as PR
objectstack-ai#18654 did.

## Not in scope, read and left alone

- **objectstack-ai#18664** (`ISSUE_BODY_LIMIT`, queued behind this card on this file)
— read, ⛔ not touched.
- **H67's own row logic** beyond the pin flip and the two sentences that
declared a loss which no longer exists.
- **The objectui copy of this file** — byte-pinned, re-synced only
through objectui#9395, ⛔ never hand-mirrored.
- **How a verdict is written to a card** — unchanged; this file still
writes nothing.

## Acceptance notes

- H66's summary clause still carries the dated reading 「Measured
2026-09-16 over 29 threads on two boards, the canonical `Release:` line
appeared ZERO times」. It names its date and its boards, so it stays true
as written, but it was taken with the bare reader and this PR changes
what a re-measure would find. Noted, not filed — the sentence is a dated
measurement, not a live claim. Who would meet it: the next author of
H66's buy-order or clause.
- `check-clause2-carriers.mjs` reads `CLAIM_COMMENT_MARKER` against a
raw comment body and therefore still cannot see a decorated claim. That
is correct for this card's file surface (the constant is unchanged) and
is a reading about that file, not a defect in this one. Noted, not
filed; the seat decides whether that gate wants the same reading. Who
would meet it: whoever next touches that gate's claim leg.

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

---------

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

Fixes objectstack-ai#18373

Clause-②: no

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

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

## What the ruling ordered, and where each piece landed

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

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

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

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

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

## 2. The two censuses — deliberately different acts

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

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

- That document's header pins it to a tree — `origin/main` at
`d30ccb9bd`, re-verified at
`1be26b0de`. Its rows are not dispositions; each one is **the result of
a behavioural
probe run against that sha**. Retiring the gate today does not make "at
`d30ccb9bd` this
  script's self-test exited non-zero when it ran zero cases" untrue.
- **Deleting** the row would break the document's own arithmetic — 179
total, 165 HELD, 4
DEFEATED, 1 ACCIDENT, 9 NOT MEASURED — and with it the whole
reconciliation the document
exists to perform against objectstack-ai#15410's competing count of 170. A record you
can subtract
  rows from is not a record.
- **Rewriting** the verdict would assert a measurement nobody took.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 5. The other repo-root tooling carriers

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

## 6. Out of scope, on purpose

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

## 7. The workflow step removal leaves the required context intact

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

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

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

## 8. `skip-changeset`, measured

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

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

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

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

## 9. Verification

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

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

## 10. Serial constraint with two in-flight PRs

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

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

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

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

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

## Acceptance notes

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e the 27 signature hashes (objectstack-ai#18971)

Fixes objectstack-ai#16045

Clause-②: yes (widening)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Where it lands, and why there

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

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

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

## Verification

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

## Acceptance notes

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

## 维护者速读(草稿)

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

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

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

**席位意见。**

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

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

---------

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

Fixes objectstack-ai#19077

Clause-②: no

Item **2** of objectstack-ai#19077 — the census-side application. Item 1
(`parseDerivedText`, the
returnable door) landed as PR objectstack-ai#19112, squash `956e0107a`; nothing here
re-does it.

Base: `origin/main` = `215840f4353`, fetched 2026-09-20T09:07Z. Every
reading below was
taken on that base in a dedicated worktree, never on the shared
checkout.

## The defect, and what it did

`scripts/tenant-audit-census.mjs` stores a declared type's text
whitespace-collapsed and
re-parses it as a synthetic alias (`type CensusReceiver = ...;`) to read
the engine-door
rule off it. A type literal may separate its members by a **newline
alone** — legal
TypeScript — and the collapse turns that separator into nothing, so the
synthesis does not
parse. Through `parseSourceFile` that did not fail the SITE: it ended
the process, and
every other site in the corpus lost its verdict with it, under a refusal
naming
`census-receiver-type.ts`, a file that does not exist in the tree.

## The repair — the four points of the minimal hunk, and where each
landed

| point | landed |
|---|---|
| ① the door rule takes the origin `sf` the type text came from |
`readTypeTextDoor(typeText, origin)`; `resolveReceiver` hands its own
`sf` at all four `inlineEngineDoorOrOther` call sites |
| ② the synthetic alias goes through `parseDerivedText` |
`readTypeTextDoor`, one call site, the only synthesis in the file |
| ③ on failure the report is printed against the SITE and the door reads
`false` | the located verdict rides on the site as `derivedFailure`;
`main()` prints it under a per-site `::error::` line |
| ④ a **declared** `NON_ENGINE_REASONS` arm, placed in
`UNDEFENDED_REASONS` | `type-text-not-round-trippable`, in both
artefacts under ENFORCEMENT |

## ⛔ Point ④ and the floor — why this PR also edits the gate

The non-negotiable is that no importer ends up exiting 0 where it exits
non-zero today.
Measured, rather than assumed: `lint.yml:2007-2008` invokes
`scripts/check-tenant-audit-census.mjs` and **never the generator**, and
that gate's own
docblock says why (`censusRefusals` exists because `runCensus`'s
findings were read by
nothing on the way to a CI verdict). So on a corpus holding such a
receiver:

- **today** — `runCensus` calls `parseSourceFile`, which exits 3; the
gate dies with it, CI red.
- **with the repair and no gate arm** — the site would be declared in
the artefacts, the
author would regenerate, and the gate would go **green**. That is the
loud process exit
traded for a quiet subtraction: the same floor drop in a different
costume.
- **as landed** — the site is classified, declared in both artefacts,
printed against its
own file and line with the parse verdict under it, **and refused** by
`censusRefusals`
through `notRoundTrippableSites`, the one spelling the generator and the
gate share.
  `main()` counts it in its exit code too, for the local run.

⇒ the exchange is a process-wide takedown for a located, per-site
refusal. Nothing that
exits non-zero today exits 0 after this, and no site leaves the
population in silence.

## Acceptance — triage's two inputs, as a pair

Both run in `tenant-audit-census.mjs`'s own self-test, which CI drives
through
`node scripts/check-tenant-audit-census.mjs --self-test`
(`lint.yml:2007`). Ten new cases;
the file's self-test goes 49 to 59, the gate's 24 to 27, and the gate's
`census refusals`
battery floor is raised 5 to 7 so the new cases cannot stop running
unnoticed.

1. **A newline-separated inline type literal is CLASSIFIED.** The case
runs the REAL round
trip — a source is parsed, `declaredTypesIn` collapses the declared type
exactly as the
census does, and the resolver reads the door off the stored text — and
reads
`other/type-text-not-round-trippable`. **Lit control:** the semicolon
spelling of the
SAME literal is still PLACED as `engine/inline type literal stating an
engine door`, so
the first case is not a door that rejects everything. Two more: the arm
is in
`UNDEFENDED_REASONS` (control: a defensible arm is not), and the failure
is carried as
   located data naming the SOURCE it was derived from.
2. **A genuinely unparseable input is still REFUSED.** A garbage type
text lands on the
same declared arm and no door is read off it, so it is never placed. The
corpus door is
untouched: sources are still read through `parseSourceFile`, whose
process-exit refusal
is pinned by `ts-parse.mjs --self-test` (landed with item 1) and
enforced for every
   `scripts/**` caller by `check:parse-guard` (exit 0 here).
**Boundary, pinned:** the verb gate runs first, so a newline-separated
literal naming no
write verb is never synthesised and cannot reach the new arm — the
repair's reach is
   exactly the defect's reach.

## The census over the live corpus, before and after

`node scripts/tenant-audit-census.mjs`, base `215840f4353`:

- **before** (2026-09-20T09:08Z): `exit 0`, **576 sources**, 227 write
call sites.
- **after** (2026-09-20T09:20Z): `exit 0`, **576 sources** — output
**byte-identical**
(`diff` exits 0), one undefended subtraction, reason
`type-not-in-corpus`, door-shaped 0.

⚠️ The previous round measured 573 sources; `main` moved between the
rounds. The escalation
trigger did **not** fire: exit 0 means no receiver of this shape exists
in the tree, so the
card stays latent and the grading stays triage's.

## Reverse verification — three ablations, each restored byte-identical

Driven through `scripts/ablation-replace.mjs`, which proves the mutation
landed on disk
(anchor count fell, replacement count rose, blob hash changed) and
proves each restore
(`blob == HEAD blob`, `git diff HEAD` empty).

1. **The door reverts to `parseSourceFile`** — the self-test does not
fail, it **dies**:
`exit 3`, printing the card's own refusal for `census-receiver-type.ts`
with
`1:78 ';' expected`. That is the defect, reproduced from the acceptance
case.
2. **The arm is removed from `UNDEFENDED_REASONS`** (point ④'s own
ablation) — RED, the
   declaration pin fails, 1 of 59.
3. **The gate's refusal loop is emptied** — RED, 2 of 27 gate cases
fail, which is the
   "quiet subtraction" costume failing to pass.

⚠️ Reported rather than quietly retried: ablation 2's first attempt was
**refused** by the
helper because the replacement text was a substring of the anchor, so
the on-disk count
could not rise. It restored and never ran the command; the rerun used a
dropping anchor.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, derived
from this tree at `ce1b5304101` (4 paths vs merge base `215840f43`,
three-dot): **64
families, all exit 0**, each captured BEFORE any pipe, reconciled with
`--ran`:
`64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN` — a DERIVED zero, every
family carrying a code.

Five of the 64 first answered `PREREQUISITE NOT MET` (exit 3 / exit 1:
not findings,
nothing measured) and were re-run after building what they read —
`pnpm --filter '@objectstack/lint...' build`, then
`pnpm --filter '@objectstack/client-react...' --filter
'@objectstack/client...' build`,
both through `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`).
All five then exit 0.

Outside that total by the tool's own accounting: 52 artifact-roster
families, 11 declared
wide-population families, 14 pending-changeset families, 2
path-scheduled CI jobs.

`pnpm lint` repo-wide is CI's run. The narrowing here is DECLARED and
carries all three
readings: (1) the reachable population comes from eslint's own config
text —
`eslint.config.mjs:328` records that this repo runs one config that
never enables
type-aware linting (no `parserOptions.project`, no typed rules) for ANY
file, measured
there with a positive control; (2) the targeted count is read from
`--format json`: 4
files, 0 errors, exit 0 — the two markdown artefacts report "File
ignored because no
matching configuration was supplied", i.e. eslint does not lint them at
all; (3)
invariance — with no type-aware linting anywhere, a 4-file diff cannot
move the verdict on
a file it does not contain. Taken at `ce1b5304101`.

## Changeset

`skip-changeset`, on three readings: (1) the root package is `private:
true`; (2) all **70**
non-private workspace packages declare a `files[]` (lit control) and
**0** of them have an
entry that climbs out of its own package directory; (3) none of the four
changed paths is
under any package directory. ⇒ root `scripts/**`, `content/docs/**` and
`docs/audits/**`
cannot be in any published payload. `Clause-②: no` follows: this moves a
build-time gate's
failure convention, not `packages/spec`'s accept set or any public
surface.

## ⚠️ Declared deviation — three files outside the dispatched file
surface

The dispatch declared `scripts/tenant-audit-census.mjs` and new tests
under `scripts/`.
This PR also touches:

- `scripts/check-tenant-audit-census.mjs` — the gate-side half of point
④, argued above.
Without it the repair lowers the floor, which the dispatch forbids
outright.
- `content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` —
GENERATED regions,
rewritten by `node scripts/tenant-audit-census.mjs --write`, which is
the only legal way
to edit them and is what `check-tenant-audit-census.mjs` demands. Point
④ puts the arm in
the enforced undefended table, so the table's prose had to name the
third cause or the
  artefact would explain a row it does not cover.

Measured before touching them, 2026-09-20T09:1xZ: **22 open PRs, 623
file rows** (PR
objectstack-ai#17076 paged past the 100-row cap so the negative is not a truncation
artefact) — **no open
PR holds any of the four paths**, nor `scripts/ts-parse.mjs`. Firing
control: the map does
carry `scripts/` rows (objectstack-ai#19024, objectstack-ai#18414, objectstack-ai#19153, objectstack-ai#18723). Dark control:
`scripts/zzz-no-such-file.mjs` has no row.

⚠️ Also in the regenerated artefacts and NOT caused by this change: the
unenforced
corpus-scale block re-stamps its date and sha and moves 573 to 576
tracked sources, because
`main` moved since it was last written.

## Acceptance notes — noted, not filed

- Triage's **route ①** (stop collapsing the declared type text at
storage time) stays
available and would let such a receiver be PLACED rather than
declared-undefended. It is
a much wider change — the collapsed text is also the display text, the
index-lookup input
and the ledger key — and it is not what the sequenced hunk asked for.
Successor: any
later card that revisits the census's stored type text. Not a defect in
the tree.
- The same defect class at the transpile door (`transpileChecked` on a
lifted snippet) was
already filed as its own card by the `domain:spec#3` seat, with the
boundary carried
  over. ⛔ No second card from here.
- No path literal was added to `scripts/tenant-audit-census.mjs` or
`ts-parse.mjs`;
  `check:watch-hint-literal` exits 0. Successor: none needed.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…-only), plus the merged-result probe (objectstack-ai#19259)

Fixes objectstack-ai#18224

Two gates were registered in the root manifest and invoked by **zero**
workflows:
`scripts/check-issue-citations.mjs` (delivered by objectstack-ai#18223 / card objectstack-ai#17512)
and
`scripts/check-merged-result.mjs` (delivered by objectstack-ai#18338 / card objectstack-ai#16287).
Both cards' declared
file surfaces excluded `.github/workflows/**`, so both devs correctly
stopped and filed the
wiring rather than widening. This PR is that wiring.

## The three entry points, and their postures

| entry | lane | posture |
| :--- | :--- | :--- |
| the DIFF-scoped citation verdict | `lint.yml` · `Lint & Repo Gates` |
**blocking** |
| `check:merged-result` self-test | `lint.yml` · `Lint & Repo Gates` |
**blocking** |
| `check-issue-citations --census` | `half-state-patrol.yml` (scheduled)
| **report-only, never blocking** |

The census posture is a ruling, not a preference, and the card carries
the measurement that
forces it: **objectstack-ai#16783, objectstack-ai#16786 and objectstack-ai#16787 were measured RESOLVING on
2026-09-10 and 404 on
2026-09-14, with no change to this tree.** A tree-wide blocking verdict
would therefore red a
motionless repository because a third party deleted an issue. The
diff-scoped half is the part
an author owns, it is small, and it is the only thing that stops 2,785
unresolvable sites
becoming 3,000.

## The manifest alias is NOT the verdict

`package.json` maps `check:issue-citations` to `node
scripts/check-issue-citations.mjs --self-test`
and nothing else -- the shape every credential-needing gate in this
manifest uses
(`check:pm-half-states`, `check:pm-closed-card-sweep`), because a live
mode needs a board and a
credential. Wiring that alias alone would have run the self-test twice
and scanned nothing. So
the lint step holds **two** commands, self-test first:

```
pnpm check:issue-citations && node scripts/check-issue-citations.mjs
```

The second spelling is lint.yml's documented gate-invocation idiom
(`dispatch-gates.mjs`'s own
header names it), and the first is what `check-self-test-wired` requires
of every script CI runs.

`check:merged-result` needed no such split: the manifest key already IS
`node scripts/check-merged-result.mjs --self-test`, which is the whole
gate.

## `package.json` is deliberately untouched

No new manifest key was added. Both keys already exist and both are now
named by `lint.yml`, so
the wiring needs no manifest edit, and leaving the file alone keeps this
PR textually disjoint
from PR objectstack-ai#18414, which adds a key two lines from where a new one would
have gone.

## The non-step changes: `issues: read` AND `pull-requests: read`

> ⚠️ Heading corrected by the dispatching PM at 2026-09-20T07:25Z: this
section was written when the block
> gained ONE scope. It now adds TWO — `pull-requests: read` followed
from a measurement taken after
> the body was written (patrol run 35495222460 censused **3628**
unresolvable sites on a token without it,
> run 35496169133 on the fixed head censused **2167**, the exact
full-scope reading; the 1460 difference is
> exactly the `resolves-as-pull-request` tally, because `GET /issues`
omits pull requests without that scope).
> ⛔ Nothing else in this body was altered.

The `Lint & Repo Gates` job's `permissions:` block gains `issues: read`.
An explicit
`permissions:` block sets every unnamed scope to `none`; this board is
public today, but a
**blocking** gate whose transport depends on repository visibility is a
gate that goes red on a
settings change no file in this repo can assert. Read-only, one scope
wide: the gate never
writes an issue, a comment, a label or an assignee.

## Acceptance 2 -- both directions measured, on the CI side

PR objectstack-ai#18223 measured the red/green pair on the **gate** side. This card
asks for the same pair on
the **CI** side. Two instruments, both here.

**A. The commits on this branch ARE the CI-side ablation.** `test(ci):
ABLATION LEG 1 of 2`
adds one citation naming a number beyond this board's allocation
frontier, in a declared surface
(`packages/**/src/**/*.ts`, comment-prose projection). `ABLATION LEG 2
of 2` removes it and
restores the file to the byte. The run on the first head is the red
reading; the run on the final
head is the green one. Both run identifiers are recorded in the dev
report on the card.

**B. The exact command the new step holds, ablated locally with disk
evidence.** Three legs, run
from a committed state, each restored with `git checkout HEAD -- path`
and proven by hash:

```
target                packages/types/src/index.ts
HEAD blob             0235d4e

leg 1  unresolvable   marker 0 -> 1   blob 0235d4e -> 2ac9ba3   exit 2   RED
       finding: [never-issued] packages/types/src/index.ts:8  #999999
                beyond the allocation frontier (19258) -- this number was never minted

leg 2  restored       marker 1 -> 0   blob back to 0235d4e       exit 0   GREEN
       `git diff HEAD` empty after the restore

leg 3  resolvable     cites objectstack-ai#17512 instead                          exit 0   GREEN
       board: probed (1 citations), frontier objectstack-ai#19258, 2 numbers resolve
```

Leg 3 is the control on leg 2: a green produced by a live board read,
not by a run that never
asked the board anything.

## Acceptance 3 -- where the census reports, how often, who pays

Written into the step itself, and repeated here:

- **Where.** The patrol run's **step summary** and job log, plus one
`::warning::` carrying the
site count. Deliberately **not** the anchor issue: that body is owned
end to end by
`check-half-states.mjs`'s generator, and a second writer is how half a
generated body goes stale.
- **How often.** This workflow's schedule -- four times a day, six hours
apart (`37 1,7,13,19`) --
plus any `workflow_dispatch`, plus the `pull_request` runs the paths
filter admits. A row for
`scripts/check-issue-citations.mjs` was added to that filter for the
reason the file already
gives for its two siblings: a step whose script can change without the
trigger firing is a step
  whose PR-time proof is a coincidence.
- **Who pays the 159 requests/run.** This repository's own
`secrets.GITHUB_TOKEN` core quota --
the same 5,000/hour the job already draws the live sweep from. Four runs
a day is roughly 636
requests/day, under half a percent of a single hour's allowance. No PAT
and no cross-repo
  credential, per this file's own standing rule.

The step is gated on `github.repository ==
'objectstack-ai/objectstack'`, exactly as the
closed-card sweep above it is, because `half-state-patrol.yml` is copied
verbatim into sibling
repos that do not carry this script -- a copy must skip the step, not
fail on a missing file. It
is placed **after** the anchor write, unlike the closed-card sweep: the
anchor is this patrol's
product, the job has a 15-minute timeout, and a report-only reading must
never be able to starve
it. It always exits 0.

## Acceptance 4 -- the 422 wall

This diff touches `.github/workflows/**`, which is outside the PM seat's
arming channel: the
seat's `auto_merge` answers **HTTP 422** for this PR. **It merges by a
human.** That is the same
wall as PR objectstack-ai#18096 and objectstack-ai#18341, it is not a tool fault, and it must not be
retried. No seat should
undraft this PR or arm auto-merge on it.

Note that `.github/workflows/**` is *not* on the `GOVERNED_SURFACES`
register in
`scripts/pm/check-governed-merges.mjs`, so the governed-merge machinery
is not what holds this
one -- the 422 is.

## Placement, and the three in-flight PRs on these files

Read before editing: objectstack-ai#18414 (`lint.yml` + `package.json`, green,
awaiting a human merge),
objectstack-ai#19024 (`lint.yml`), objectstack-ai#19225 (`half-state-patrol.yml`, draft and frozen).
Only additions here; no
existing step was moved, renumbered or reformatted.

- In `lint.yml` the two steps go **above** the `objectstack-ai#15149`
step-name-quoting step, which keeps that
step's own documented placement ("immediately above" the
duration-unit-keys step) true and keeps
the duration-unit-keys step last among the gates. objectstack-ai#18414 inserts at the
control-byte guard
(line ~411) and objectstack-ai#19024 edits the typecheck lanes (lines ~5173 and
~6104), so all three hunks are
  disjoint.
- In `half-state-patrol.yml` the census step goes between the summary
publish and the final
fail step. objectstack-ai#19225 rewrites that file wholesale into a composite action
and is frozen behind a
`pm:blocked` card; a textual conflict there is expected and was accepted
at dispatch.

## Measurement this PR does not relay

The card's prose carries three disagreeing counts for the manifest
census. Re-measured on this
branch's base `0f42d36ff`, with a firing control and a dark control:

```
root manifest check:* keys                 165
not named by any workflow (name or path)     2   -> check:merged-result, check:issue-citations
...and not named by any other script         2
firing control 'check:nul-bytes' in a wf   true
dark control   'check:zznotreal' in a wf   false
```

Both unreached keys are the two this PR wires, so the reading after this
lands is 0.

## Acceptance notes

- `check-self-test-wired` admits a script when a workflow names it
directly or through a root
manifest alias, repo-wide rather than per workflow, so the patrol's
census step needs no second
  self-test invocation: the lint step above already runs it.
- No changeset: nothing any package publishes moves. Workflow files ship
in no package's `files[]`.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: os-elon-musk <elon-musk@objectstack.ai>
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 size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants