Repository navigation
feat(scripts): refuse an undeclared mode-160000 gitlink in the index - #18414
Conversation
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>
PM review — ACCEPTReviewed against GitHub and against a detached worktree at this PR's head The gate, exercised by me rather than by its own harness
⭐ 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
Also re-derived here, independently: The two shape deviations — both acceptedThe card's suggested shape was report-only-then-refusing. This ships refusing from day one, with the report half as Fences, checked one by one
The declared narrowing, and why it stands5 of the 63 derived families exited 3 — PREREQUISITE NOT MET, read as NOT MEASURED rather than as a pass: ⭐ And the slow gate was not allowed to become a NOT MEASURED: Two things the dev handed me, and what I did with each
Landing⛔ This PR is human-merge only and I am not arming it: the diff touches PM seat Generated by Claude Code |
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 ( Why you and not the queue: the diff touches State, all three read after the ready flip — marking ready re-triggers What it is: a new ⭐ The one reading worth your time before merging. The gate reads the declaration from the index blob (
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 PM seat Generated by Claude Code |
两条巡查行的答复,连同它们各自要求的读数本轮 H36 —— 与 PR #18503 共享
|
| 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)。
本席武装不了它,而且这不是失误。 它的 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
…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>
… 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>
…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>
…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>
…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>
…-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>
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 -Aover a nested git repository — a linked worktree, a nested clone, a vendored checkout — staged exactly one entry at mode160000at exit 0 with only awarning: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.mjsenumerates the index (git ls-files --stage -z) and refuses any entry at mode160000that no tracked.gitmodulesrow declares. Rootpackage.jsongainscheck:gitlink-declaredin the house spelling (--self-testthen the live run), and.github/workflows/lint.ymlgains an unconditional step inLint & Repo Gates, besideRaw 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:
git submodule addwrites 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.git config --blob :.gitmodules, not off the working tree. A.gitmodulespresent 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.--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.--listprints 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..githooks/pre-commitwiring. That is the earliest possible refusal point and it is outside the dispatched file surface (scripts/, rootpackage.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 inheritedGIT_INDEX_FILE— the index a hook is being asked about — is the index it judges.No new runtime dependency:
gititself is the.gitmodulesparser (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.Its forward fixture is the card's own measurement, reproduced with a literal
git add -Arather than a hand-assembled index, and the fixture is asserted before the gate is (a run in whichgit add -Astaged 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:The nested repository sits at
vendor/thingrather 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:
The gitlink count is printed unconditionally,
0included: 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 inscripts/check-nul-bytes.mjs". Re-derived at7358c1c5bwith controls taken from the probed tree itself:git grep 160000across the treegit grep -i gitlinkscripts/check-nul-bytes.mjs:193,:521, both prose skip commentsgit grep -ic nul scripts/check-nul-bytes.mjsgit grep -i gitmodules.gitmodulesfile existsgit ls-files --stagemode histogram100644× 8691,100755× 34 — no other modeThe substance holds and holds harder than the card claimed; the citation form does not. ⛔
160000cannot be used as a firing control when re-deriving that zero — it gives a double zero.Changeset:
skip-changeset, measured againstfiles[]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:package.jsonmanifestsprivate: true)files[](i.e. shipping their whole directory)files[]entries escaping their own package directory (../or a leading slash)files[]entries namingscriptsat all@objectstack/specfiles[]dist,json-schema,liveness,prompts,llms.txt,README.md,src/**/*.zod.ts,CHANGELOG.md,api-surface,spec-changes.jsonThe root manifest is
@objectstack/spec-monorepo,private: true— it is never published. Every published package declares afiles[]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/objectstackderives 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
bfa686a61for the sweep and298e245defor 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-contentandcheck:type-check-debtall 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 nofiles[]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 commit298e245de:eslint . --no-inline-config --format jsonover 6790 files, 0 errors, 0 warnings, exit 0. The file count is read off eslint's own--format jsonoutput and the population is eslint's own config resolution, not a guess; this repo's singleeslint.config.mjsnever enables type-aware linting (noparserOptions.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 commit298e245dethe 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
-zdelimiter 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 sourcecheck-nul-bytes.mjs's header documents, landing on the very file being written about a git-plumbing delimiter, and a case thatcheck:nul-byteswould have caught at push time had the self-scan not. Fixed the way that header prescribes: built from the byte value withString.fromCharCode(0), never written as a literal anywhere in the file..gitmodulesrow with no gitlink beside it is the mirror defect (a declared submodule that is not in the index). It is reported by--listand 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-commitis 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, andfindOffendersis pinned on that shape.Generated by Claude Code