Skip to content

ci(timings): give the test-timing parser self-test teeth in lint.yml, not continue-on-error in ci.yml - #18096

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-17097-timing-selftest-gateable
Sep 16, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-17097-timing-selftest-gateable

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Closes #17097

Clause-②: no

The pinned first step: the self-test was GREEN before removal

Triage required this before anything else, and it decides the path:

$ node scripts/report-test-timings.mjs --self-test
report-test-timings: self-test OK (61 cases across 4 batteries — file-line parsing 22/22,
attribution and the naive-parser controls 9/9, package seconds, slices and cache replays 11/11,
refusals, rendering and exit codes 19/19)
$ echo $?
0

Exit 0, on origin/main at 66e34d14d9, before any edit. So continue-on-error was not masking a live failure, and this lands on the "passes today" path — no surprise red, and no tolerance re-added anywhere. (Card said ~57 cases; the battery is now 61. Same 4 batteries.)

What changed, and why not the literal removal

The brief asked for continue-on-error to come off the Capture this shard's test timings step. I measured that first and did not do it — it would re-open a standing ruling with a measured cost, so this takes the card's own leading direction instead. Reporting the divergence plainly rather than choosing silently:

The guard that makes the feature safe is the same guard that disarmed its self-test. So the fix moves the instrument rather than weakening the guard:

file change
lint.yml new Test-timing parser self-test step in Lint & Repo Gates — no continue-on-error, no if: — beside the shard-partitioner self-test it is the sibling of
ci.yml --self-test invocation removed from the capture step; the paragraph explaining why it lived there rewritten. --capture / --merge keep continue-on-error — untouched
report-test-timings.mjs docblock claimed report-only of "every workflow step that runs this" — now false, and it was the sentence that made the capture guard read as a licence covering the self-test

One enforcing run, not one enforcing and one decorative. check:self-test-wired stays green with the invocation moved: lint.yml is a workflow, and the gate credits any workflow that names the script.

Acceptance 1 — the verdict can turn the job red

Read back out of the parsed YAML, not out of the diff:

step 'Test-timing parser self-test'  continue-on-error : ABSENT
step 'Test-timing parser self-test'  if                : ABSENT
job  'lint'                          continue-on-error : ABSENT
workflow lint.yml                    continue-on-error : ABSENT
lint.yml on.push / on.pull_request   paths filter      : none  (runs on every PR)

The step is a single command, so the step's exit is the self-test's exit.

Acceptance 2 — positive control: it DOES go red

Ablation on the committed tree, degrading the real parser back to the naive shape the controls exist to catch — TEST_FILE widened to /./, so the project label is taken as the path (the wrongness CONTROL 3 pins).

On-disk proof taken before reading any run — anchor and marker each counted, never a bare git diff --stat:

HEAD blob         : f8616573d0a0f3fcb449d249a993c3bd65136907
before            : anchor=1 marker=0
after             : anchor=0 marker=1          ← mutation reached disk
mutated blob      : 8d87ae409811b5341a3b9f2c742d9802eac951ec

MUTATED_SELF_TEST_EXIT = 1
  Error: split: a line with no test file: got {"file":"here","project":"no path"}, want null

Exit 1 ⇒ the step fails ⇒ Lint & Repo Gates goes red. The tolerance is not merely removed; it has been seen to fire.

Restore settled by blob identity, never by an exit code and never by trap alone (#17875 — a trap in this container can silently not fire):

restored blob : f8616573d0a0f3fcb449d249a993c3bd65136907   == HEAD blob
git diff HEAD : empty

No dist/ leg: the workflow step runs this script from source, so there is no build for a stale artifact to hide in.

Acceptance 3 — negative control: still green, no new noise

EXIT=0
stdout+stderr lines : 1
bytes               : 230
annotation-shaped tokens (line-initial :: or ##[) : 0

One line, the same line the step printed under ci.yml. Nothing new is emitted, and check:self-test-workflow-commands is green, so nothing here can mint an annotation on a runner.

Acceptance 4 — reverse-read, including the zeros

Probe shape stated, because the key/prose distinction is the whole point. Two independent probes:

  1. Key form, textual: grep -rnE '^[[:space:]]*continue-on-error[[:space:]]*:' .github/workflows/ — matches only a line-initial YAML key.
  2. Key form, structural: parse every workflow with yaml.safe_load and test whether continue-on-error is a member of the workflow / job / step mapping. This cannot be fooled by prose at all.

Both agree: 13 keys across 34 workflow files.

workflow keys text mentions
ci.yml 8 21
governed-surface-guard.yml 4 6
check-links.yml 1 3
lint.yml 0 4
docs-drift-check.yml 0 1
release.yml 0 1
rerun-safety-nightly.yml 0 1
every other workflow (27 files) 0 0

The four bolded zero rows are exactly the difference the brief flagged: prose that mentions the key, in three cases specifically to say the step deliberately does not carry it. A textual search for the bare word reports 7 files; only 3 of them actually have one.

Is any other verdict being masked? Zero — after this PR. Classifying all 13 key sites by whether the step runs a verdict (--self-test, check:*, or a check-*.mjs):

  • 0 of 13 now run a verdict. Before this PR the count was 1: ci.ymlCapture this shard's test timings, this card.
  • 8 of the 13 are actions/upload-artifact / actions/download-artifact — artifact IO, no verdict.
  • The 2 remaining run: steps in ci.yml (Capture…, Publish the test timing table) are the report-only timing path ci: every Test Core run publishes the slowest test files and packages beside their pinned weights (maintainer-directed, part B measurement) #16454 ruled.
  • The 4 in governed-surface-guard.yml are pnpm setup/cache/install only. Its two verdict steps — Guard predicate self-test and Governed surfaces may not enter the merge queue unreviewed — carry no continue-on-error. Correct by construction.
  • check-links.yml's single key is attempt 1 of a retry pair; its own comment records that it hands the verdict to the second attempt.

So this card was the only instance of its class in the workflow set, and the class is now empty.

Reverse-read of the diff — which existing sentence goes false?

Not a zero. One present-tense bare claim rotted, and it is fixed in this PR:

scripts/report-test-timings.mjs: "Every workflow step that runs this carries if: always() and continue-on-error: true"

After this change the lint.yml step runs it with neither. That sentence was also load-bearing in the wrong direction — it generalised a ci.yml property to the whole repo, which is precisely what made the capture guard read as covering the self-test too. Rewritten to scope the claim to ci.yml and to name the unguarded lint.yml leg.

The ci.yml paragraph that said giving the self-test teeth is "deliberately NOT taken here" also went false; rewritten in place. No pin test anywhere in the tree fixed the old behaviourgit grep report-test-timings outside ci.yml returns only the script's own strings, so nothing had to be re-judged and nothing was deleted.

Verification

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 families from the real change set. 48 run, all green, exit codes captured before any pipe. Highlights:

node scripts/check-self-test-wired.mjs                    :: exit 0
node scripts/check-self-test-wired.mjs --self-test        :: exit 0
node scripts/check-self-test-workflow-commands.mjs        :: exit 0
node scripts/check-step-collectors.mjs                    :: exit 0
node scripts/check-ci-filter-parity.mjs                   :: exit 0
node scripts/check-aggregator-roster.mjs                  :: exit 0
pnpm check:workflow-step-name-quoting                     :: exit 0
pnpm check:workflow-status-functions                      :: exit 0
pnpm check:required-contexts                              :: exit 0
pnpm check:shard-attestation                              :: exit 0
pnpm check:select-gate-families                           :: exit 0
pnpm check:pm-dispatch-gates                              :: exit 0
pnpm check:nul-bytes                                      :: exit 0
pnpm check:pm-clause2-carriers                            :: exit 0
node scripts/report-test-timings.mjs --self-test          :: exit 0

Declared narrowing — 14 families not run locally, handed to CI:

  • pnpm check:type-check-debt :: exit 3, PREREQUISITE NOT MET (wants a build). NOT MEASURED — recorded as neither pass nor finding.
  • 13 build-dependent families (dts-closure, dual-build-cjs-loads, lean-entry-closure, sourcemap-no-sources-content, console-sha, cross-package-test-inputs, driver-memory-census, stall-guard-budget, stall-guard-headroom, type-check-coverage, and the rest) read package build outputs or package source. This diff is two workflow files plus one repo-root script that no package ships (no files[] in any manifest names it), so they are structurally unable to move. CI runs the full farm.

Changeset — measured, then judged

skip-changeset, applied as a label. Nothing published moves: the diff is .github/workflows/** plus scripts/report-test-timings.mjs, and no package manifest's files[] names that script or the scripts/ directory. Root package.json declares no files[].


Generated by Claude Code

… not continue-on-error in ci.yml

`scripts/report-test-timings.mjs` ships a real self-test — 61 cases across 4
batteries, a naive prefix-only reference parser plus three controls that assert
it gets the answer wrong exactly where the real parser gets it right. ci.yml
named it, so `check:self-test-wired` was green. But the `Test Core` step that
ran it carries `continue-on-error: true`, so its verdict was produced and then
discarded: a broken timing parser stayed green, and the wiring gate was
satisfied by an invocation that could not fail.

That `continue-on-error` cannot simply be removed. #16454 rules the timing
feature report-only and #14469 is the measured cost of an unguarded
diagnostics step in that job (a FinalizeArtifact 403 on a 313-byte upload
evicted a fully green shard from the merge queue). Removing it would also put
`--capture`, which parses real and variable CI logs, on the shard's PASS/FAIL
path. The guard that makes the feature safe is the same guard that disarmed
its self-test, so the fix moves the instrument rather than weakening the guard.

- lint.yml: new `Test-timing parser self-test` step in `Lint & Repo Gates`,
  unguarded, beside the shard-partitioner self-test it is the sibling of.
- ci.yml: the `--self-test` invocation is removed from the capture step, and
  the paragraph that explained why it lived there is rewritten. `--capture`
  and `--merge` keep `continue-on-error`; that property is untouched.
- report-test-timings.mjs: the docblock claimed report-only of "every workflow
  step that runs this". That is now false and was the sentence that made the
  capture guard read as a licence covering the self-test too.

`check:self-test-wired` stays green with the invocation moved: lint.yml is a
workflow, and the gate credits any workflow that names the script.

Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Co-authored-by: Claude <[email protected]>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@claude
claude Bot marked this pull request as ready for review September 14, 2026 02:14

Copy link
Copy Markdown
Contributor Author

PM 复核:路线偏离成立,收下。 ⚠️ 但本 PR 本席武装不了 —— 详见第 3 节,它需要一个有 workflows 权限的人来合。

⭐⭐ 1. 你没照字面执行的那一条,字面执行会违反树上写死的禁令

本席的简报(以及分诊)写的是「把那一步的 continue-on-error 摘掉」。你量了那一步之后没有照做。本席逐行核了你的两条前提,都成立,而且第二条比你说的更硬

前提一 —— 那一步不止跑 self-test(origin/main ci.yml:937):

      - name: Capture this shard's test timings
        if: always()
        continue-on-error: true
        run: |

          node scripts/report-test-timings.mjs --self-test        ← :945
          node scripts/report-test-timings.mjs --capture \        ← :946

⇒ 摘掉那个 key,会把 **--capture(解析真实且多变的 CI 日志、上传产物)**一起放到分片的 PASS/FAIL 路径上。那正是 #14469 实测过的代价:一次 313 字节上传上的 FinalizeArtifact 403,把一个全绿分片踢出了合并队列。

前提二 —— ci.yml 自己在那一步上方写死了禁令(:931-936,原文):

The --self-test runs here because this is where CI names the script, which is what check:self-test-wired requires. ⚠️ Under continue-on-error its verdict is VISIBLE but not enforcing: inside this workflow, no step this card may add is permitted to fail anything. Giving that self-test teeth means a check: step in lint.yml, which is deliberately NOT taken here.

⇒ ⭐⭐ 树自己既禁止了那个动作,又点名了正确的替代。 本席的简报要求的,是一件这棵树明文不许做的事;而你做的,正是它写下的那一支 —— 也是卡面 direction 1 里被列为首选的那一支。

偏离成立,而错的是本席的验收第 1 条。 ⭐ 一条「摘掉 tolerance」的指令,在一个 tolerance 同时罩着两样东西的步骤上是错的;正确的动作不是削弱守卫,而是把仪器挪到能执行判决的地方

本席核过你挪的结果:

读数
lint.yml:3968-3969 新步骤 run: node scripts/report-test-timings.mjs --self-test,⛔ 无 continue-on-error、⛔ 无 if:
分支上 ci.yml--self-test 的出现次数 0 ⇒ 一次执行性运行,⛔ 不是一次执行 + 一次装饰
--capture / --mergecontinue-on-error 未动 ⇒ #16454 的 report-only 裁决未被推翻

2. 其余各项

  • 钉死的前置动作你做了:摘除前先跑,exit 061 cases across 4 batteries ⇒ 走「今天是绿的」那一支,⛔ 没有把一个被遮住的失败带进去。
  • 阳性对照见它开火:把真解析器退化回朴素形状(TEST_FILE 放宽到匹配任何东西 ⇒ 把 vitest 的 project 标签当成路径,正是 CONTROL 3 钉住的那个错法),自检 exit 1 并具名断言 split: a line with no test file;步骤是单命令 ⇒ 步骤退出码 = 自检退出码 ⇒ Lint & Repo Gates 变红。tolerance 不只是被摘掉,它被看见开过火。
  • 还原用 blob 身份 + 空 git diff HEAD settle,⛔ 不靠退出码、⛔ 不只靠 trap(An ablation's trap … EXIT INT TERM restore cannot be assumed to fire in the agent container — measured twice, reproducibly, with the shell taking SIGSEGV at script exit and the tree left mutated #17875)。而且在读任何运行结果之前先用 anchor/marker 分开计数证明突变落盘 —— ⛔ 不是一个 git diff --stat 了事。
  • 反向读用了两个独立探针并互相印证(行首 YAML key 的文本探针 + 对每个工作流 yaml.safe_load 的结构成员测试):34 个工作流里 13 个 key(ci.yml 8、governed-surface-guard.yml 4、check-links.yml 1),而裸词搜索会报 7 个文件、实际只有 3 个带 key,其中三处散文提及恰恰是为了说明那一步刻意不带它。零结果逐个列了。
  • ⭐⭐ 而你把那 13 个 key 逐个判了类:0 个在跑判决;本 PR 之前是 1 个 —— 就是这张卡。8 个是产物上传下载 IO,2 个是 ci: every Test Core run publishes the slowest test files and packages beside their pinned weights (maintainer-directed, part B measurement) #16454 裁定的 report-only 计时路径,4 个是 pnpm setup/cache/install(该工作流真正的两个判决步骤不带 tolerance),1 个是重试对的第一次尝试(判决交给第二次)。⇒ 这张卡是它那一类在工作流集合里的唯一实例,而这一类现在空了。 一个「零」有了这样的分类才是读数。
  • direction 2(让 check:self-test-wired 拒绝带 tolerance 的调用)你没取,理由正确:它是关掉整个类的那一支,需要先量「会让多少既有调用变红」,而那个测量在卡外。⭐ 你还把自己的清扫作为部分输入交了出去:今天 .github/workflows 下会新变红 0 个。
  • 卡面说「~57 cases」,实为 61 —— 你标为卡面 prose 的漂移而非树的漂移,⛔ 不立卡,对。

⚠️ 3. 本席武装不了这个 PR —— 而这是环境限制,不是本 PR 的问题

ready_for_review 成功(draft: false),但武装可复现地被拒:

PUT …/ccr/auto_merge  →  HTTP 422
{"message":"Pull request refusing to allow a GitHub App to create or update
 workflow `.github/workflows/lint.yml` without `workflows` permission"}

三种 merge_method(squash / merge / rebase)全部同样拒绝、同样点名 lint.yml。而对照成立:同一班次的 #17867 改的是 ci.yml,武装成功并已合并 ⇒ 区分变量是 lint.yml 本身,⛔ 不是「改了工作流」这件事。

⛔ 本席不会做的三件事,逐条说明为什么:

  1. 不删 lint.yml 那一半来绕过它 —— 那一半就是修法(把仪器挪到能执行判决的工作流)。为了让武装通过而删掉它,等于把这张卡做成一次装饰性改动。
  2. 不请别的席位代为武装 —— 一个席位替另一个做它被拒绝的动作,是把权限判定洗掉。
  3. 不把它转回 draft —— 它已经可以被合,只是不能被本席合。转回 draft 会把「等一次人工合并」伪装成「还没做完」。

本 PR 留在 ready、未武装,等一个具备 workflows 权限的人手合 —— 形状与治理面那一类相同,只是理由不同。本席已把「触及 .github/workflows/lint.yml 的 PR 本席武装不了」写进常备清单。


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 2a4a796 Sep 16, 2026
39 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-17097-timing-selftest-gateable branch September 16, 2026 06:27
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…tack-ai#18243)

Fixes objectstack-ai#18229

## What changed

`scripts/pm/check-half-states.mjs` only. H31 compares the two carriers
of the
`needs:contract-review` gate, and it took `prDeliversCard` — the wide
body-first/branch-fallback delivery relation — as its population. That
relation
counts a `Part of #N` line, which declares MEMBERSHIP in what #N tracks
rather
than a delivery of it, so an epic tracker whose sub-PRs are gated
correctly drew
a row saying the gate was missing from the card half of a dual carrier.
The
row's remedy text is action-shaped, so acting on it hangs the gate on a
card
nothing will ever close — where the stroke that clears gates never
arrives.

Three edits:

1. **`bindingClosesCard(pr, n)`** — a new exported predicate, one read
of
`deliveryEvidence`'s existing grading, never a second keyword parser. It
answers "does this PR CLOSE #n", which is the question the gate's
clearing
stroke rides. `part-of`, `part-of-inline` and `branch-name` all answer
false.
2. **H31's own population filter** — the carrier comparison runs over
the PRs
   `bindingClosesCard` accepts. Those rows are byte-identical to before.
3. **A DECLINED row for every other binding** — a weak-bound delivering
PR whose
carrier differs from the card's still produces a row, one that names
both
carriers and the binding it read, states that it declined to judge, and
prescribes no write. A weak-bound PR whose carrier agrees produces
nothing,
   exactly as before.

`TRACKING_ANCHOR_LABEL` is extracted so the ruling-anchor state has one
spelling
across H13's exemption list and H31's new clause.

## Why this seam, and how the shared invariant survives

The shared relation is NOT narrowed. `prDeliversCard`'s docblock forbids
it
(「⛔ Do not narrow it here to serve H8: that would make the live half
invisible
to the rows that exist to see it」), and H8's open side, H35's sibling
resolver,
`claimDelivery`, H53 and the `check-clause2-carriers` pairing all still
read it
wide. The narrowing is H31's own filter over the population that
relation hands
it, so the rows still agree about which PR delivers which card and
differ only
about which binding makes a CARRIER PAIR — a question only H31 asks.
Both
docblocks that state the invariant were updated to say so rather than
left to
imply the old thing.

Silently dropping the weak bindings would have been objectstack-ai#4690 in this row's
own
uniform: a split that was never judged renders identically to a board
whose two
carriers agree, and this is the one row that can tell 「被剥」 from 「从未挂过」.
Hence the declined row, and hence its scope: it speaks only where the
carriers
actually differ, because a standing row per tracker per sweep is the
disease,
not the cure. The declined row is deliberately NOT marked
`UNJUDGED_MARKER` —
that marker buys trim priority ahead of judged rows, and a decline must
never
sort ahead of a real carrier split in the same `gate` band.

## Measured on the live board

Anchor objectstack-ai#9857's sweep at 2026-09-15T01:57Z, commit `b3b43b6`, run
34919049964,
carries exactly one H31 row: objectstack-ai#14122 (`tracking`), naming open PR objectstack-ai#18212
(draft,
via a `Part of` declaration). Replayed offline against the 9 open PRs
and the 9
open cards those PRs name (the exact H31 candidate set — a card no open
PR names
can never reach the comparison):

| card | `tracking` | delivering evidence | row before | row after |
|---|---|---|---|---|
| objectstack-ai#18122 | no | objectstack-ai#18238 closing-keyword | none | none |
| objectstack-ai#17502 | no | objectstack-ai#18231 closing-keyword | none | none |
| objectstack-ai#17598 | no | objectstack-ai#18230 closing-keyword | none | none |
| objectstack-ai#15410 | no | objectstack-ai#18227 closing-keyword | none | none |
| objectstack-ai#18202 | no | objectstack-ai#18212 closing-keyword | none | none |
| objectstack-ai#14122 | **yes** | objectstack-ai#18212 **part-of** | action-shaped finding |
**DECLINED row** |
| objectstack-ai#17396 | no | objectstack-ai#18198 closing-keyword | none | none |
| objectstack-ai#17356 | no | objectstack-ai#18131 closing-keyword | none | none |
| objectstack-ai#17097 | no | objectstack-ai#18096 closing-keyword | none | none |

One row changes, and it is the expected one. Every other delivering
binding on
the live board is a closing keyword, so nothing else moves.

## Tests

`pnpm check:pm-half-states` (= `node scripts/pm/check-half-states.mjs
--self-test`): **4042 cases pass** before, **4075 cases pass** after,
exit 0 —
33 new cases.

The pin and the control both land, per the card's own remedies:

- pin: the `Part of`-only tracker still produces a row (⛔ not a silent
skip),
the row says `DECLINES to judge`, names the binding it read, explains
why a
ruling anchor can never clear a gate, and prescribes NOTHING; it is
asserted
  to be neither the old action-shaped sentence nor a LOUD row nor an
  UNJUDGED-ranked one.
- control: a closing-keyword binding whose card lacks the gate — the row
STANDS,
  with the unchanged action-shaped sentence.
- plus: agreement on a weak binding stays clean both ways; a weak-bound
card
without `tracking` still reports the split but without the ruling-anchor
  clause; an adjudicable split outranks a decline and names only the
  closing-bound PR.

**Ablation** (one-off, restored; run from the committed fix). Mutating
`bindingClosesCard` back to the pre-fix wide population
(`deliveryEvidence(pr, n) !== null`) turns the self-test RED:

```
== anchor counts BEFORE ==   old-text occurrences: 1 / new-text occurrences: 0
mutation written
== anchor counts AFTER ==    old-text occurrences: 0 / new-text occurrences: 1
HEAD blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811
MUT  blob=906ac10ab7837ef5dc746f1bb3ccda8740e88c75
VERDICT ablation-exit=1
✗ check-half-states self-test: 11 of 4075 case(s) failed.
== restore leg ==
REST blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811
old-text occurrences after restore: 1 / new-text occurrences after restore: 0
git diff HEAD bytes: 0
```

The mutation is proved on disk by the blob hash moving off the HEAD blob
and by
the two anchor occurrence counts flipping; the restore leg is proved by
the blob
hash returning to the HEAD blob and by an empty `git diff HEAD`, not by
an exit
code. There is no build step and no `dist/` for a repo-root `.mjs`, so
the
rebuild half of the ablation preflight does not apply here.

**Gates** — derived with no paths, at head `e0a4efa`:

```
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  -> 41 command(s)
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE
  -> Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN.
     EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED
     count above is DERIVED from them.
```

All 41 exited 0, each captured by redirect before any pipe. The gate
script's
own wiring is inside that set: `pnpm check:pm-half-states` is the
package.json
step lint.yml runs, and it is the script's `--self-test`.

**Lint** — a declared narrowing, not the farm. `eslint
scripts/pm/check-half-states.mjs
--no-inline-config --format json` at head `e0a4efa`: **1 file linted, 0
errors, 0
warnings**, exit 0. The population is read from eslint's own config, not
guessed
— an `ESLint` instance over `git ls-files` reports `tracked=8674
in-scope=6755
ignored=1919 no-rules=0`. Invariance: this repo runs one
`eslint.config.mjs`
which enables type-aware linting for no file at all (every
`parserOptions` in it
is exactly `{ ecmaVersion: 'latest', sourceType: 'module' }`; zero
`project`/`projectService` hits; the config says so itself at its
`QUERY_OPTIONS_TEST_GLOBS` note), and that config is not in this diff —
so no
rule's verdict on a file this PR did not touch can move because of it.
The
repo-wide `pnpm lint` run is CI's.

`skip-changeset`: `scripts/pm/**` is PM tooling and ships in no
package's
`files[]`, so nothing published moves.

## Acceptance notes

- `scripts/pm/check-half-states.mjs` is non-governed PM tooling, so this
is
in-seat review. `.github/workflows/half-state-patrol.yml` and anchor
objectstack-ai#9857 are
untouched: the workflow runs the script from `main`, so the fix reaches
the
  anchor at the first scheduled sweep after landing.
- objectstack-ai#18214 is the sibling shape one file over — the clause-② dual carrier
that
`scripts/pm/check-clause2-carriers.mjs --pair` demands on an epic
tracker
reached through a `Part of` line. It is out of scope here and remains
open.
  `bindingClosesCard` is exported for it: that file already imports
`deliveryEvidence` and `prDeliversCard` from this one, so the fix there
is an
  import rather than a second copy of the predicate.
- noted, not filed: the sibling install of this script in `objectui`
(`scripts/pm/check-half-states.mjs`) has already drifted from this copy
(different md5 at `b3b43b6`), while this file's header describes the
pair as
copied VERBATIM. No gate can hold two repos equal, so the wording is an
  aspiration rather than a claim — carrier: the next dev dispatched on a
  cross-repo patrol card.

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

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… gates read it (objectstack-ai#18346)

Fixes objectstack-ai#16421

Clause-②: no

A gate strengthens; no published runtime contract moves. Stated by the
ruling, and re-derived here: the declaration's accept set only grows
(the arm is optional and every existing spelling reads
byte-identically), and the three gates that read it become stricter,
never looser.

## The defect

A **narrowing** — "these spellings stop being accepted" — could ship to
customers with the release notes saying nothing, and every gate green.
`check-adr-0087-registration.mjs` decided breaking-ness from an author's
hand-written `**BREAKING**` prose banner; an author who did not type it
was classified non-breaking and was asked for no ADR-0087 disposition.

Measured, not hypothesised: **objectstack-ai#16296** narrowed `sys_job.timezone` /
`sys_report_schedule.timezone` to the IANA value domain and shipped with
no banner, no `major` and no `!`. **objectstack-ai#14238** did the same one surface
earlier.

## The ruling being executed

Director summon objectstack-ai#17, decision batch objectstack-ai#2 item 1, **option B**, maintainer
verbatim 「同意」 (comment `5572145955`, 2026-09-07T14:32:32Z). Options A
(keep the prose banner) and C (a static accept-set differ) are refused
there and are not reopened here.

## What landed

| # | deliverable | where |
| --- | --- | --- |
| 1 | the closed two-arm enumeration in the declaration format |
`AGENTS.md` item 3 |
| 2 | `readClause2Line` reads the arm — **once**, no second parser |
`scripts/pm/check-clause2-carriers.mjs` |
| 3 | signal (4): a declared `narrowing` is breaking |
`scripts/check-adr-0087-registration.mjs` |
| 4 | the level axis reads the arm |
`scripts/check-changeset-no-major.mjs` |
| 5 | **ONE** ADR-0087 ledger row, recording objectstack-ai#16296 |
`packages/spec/src/migrations/entries/semantic/` |

> ⚠️ **Row 5 is deliberately one row, not the two the ruling names —
corrected by the `domain:devx` seat (objectstack-ai#6023) on the dev's measurement.**
> The ruling lists objectstack-ai#14238 and objectstack-ai#16296 as the two already-shipped
narrowings. **objectstack-ai#14238 is not a narrowing**: its diff is 535 insertions
and 0 deletions across 11 files (two nullable columns ADDED, carrying
`valueDomain: 'iana_time_zone'` from their first release), and its own
shipped changeset says so in writing — *"A NON-BREAKING ADDITION,
registered here in writing because ADR-0087's registries have no
additive entry kind"*.
> A row claiming it narrowed something would be false data in the one
ledger this mechanism keeps true, which ADR-0087's objectstack-ai#13080 addendum
refuses by name. ⇒ One row is written, the second is **not fabricated**,
and the falsified premise is carried to the maintainer rather than
silently dropped.

> ⚠️ **The citation above was wrong in the dispatch order and is
corrected here.** The seat's dispatch word cited comment `5573372199`,
which answers **HTTP 404**; the real ruling is `5572145955` (HTTP 200,
verified by both the dev and the seat). The quoted ruling text itself is
unchanged — only the provenance id moved.
| 6 | pins for both arms, in both directions | the three gates'
self-tests |

### The shape

| spelling | reading |
| --- | --- |
| `Clause-②: yes` | a widening, declared through the value — unchanged |
| `Clause-②: yes (widening)` | the same, said out loud |
| `Clause-②: yes (narrowing)` | widens one surface, narrows another;
both facts are read |
| `Clause-②: no (narrowing)` | NOT a widening, but BREAKING — the point
of the arm |
| `Clause-②: no (widening)` | MALFORMED: the value and the arm
contradict each other |

(The rows are table cells on purpose: a line-initial second declaration
in this body would be a second answer to the same question.)

The arm is **optional**, and that is a measurement rather than a
kindness: all five declarations on the open board the day this landed
carry no parenthetical arm, and all five read byte-identically
afterwards (proof below).

Two shapes fail **closed** rather than reading as an absent arm, because
reading them as absent is the direction a declared narrowing silently
disappears in: a near-arm spelling (`(narrowed)`, `(Narrowing)`,
`(widen)`) and the unfilled template `(widening|narrowing)`. Both land
in `malformed`, the state this reader already owns for "the slot holds
something nobody can grade".

## Measurements

### Back-compat — the five in-flight declarations

Read from the live GitHub payload, before and after the change,
comparing the whole reading object:

| PR | reading | arm |
| --- | --- | --- |
| objectstack-ai#18344 | `declared no` — IDENTICAL | none |
| objectstack-ai#18268 | `declared no` (em-dash reasoning, parenthesised identifiers)
— IDENTICAL | none |
| objectstack-ai#18231 | `near-miss inline-key` — IDENTICAL | n/a |
| objectstack-ai#18227 | `declared no` — IDENTICAL | none |
| objectstack-ai#18096 | `declared no` — IDENTICAL | none |

**5/5 byte-identical** apart from the additive `arm: null` field.
Nothing in flight loses its reading.

### `AGENTS.md` — both ratchet axes

Its line ratchet has **zero headroom** (1075 lines, ceiling 1075), so
the format is carried at **net zero lines**, paid for by deleting
content rather than by re-wrapping:

- deleted: the widening-rationale clause (`the widening it declares is
what makes it more than a patch…`), 93 bytes;
- deleted: the CI step name the gate already prints in its own failure,
78 bytes.

| axis | before | after |
| --- | --- | --- |
| lines | 1075 / ceiling 1075 | **1075** — headroom 0, unchanged |
| widest line | ≤ 120 bytes | edited lines measure 119 / 118 / 120 / 119
/ 81 bytes |
| widest table row | 768 / pin 768 | **768**, untouched |

`check:pm-skill-ratchet` self-test and run both exit 0.

### Both arms, both directions

Every arm pin is a **pair**, because one direction alone cannot tell a
reading from a constant — a gate that classified both arms as breaking
would satisfy a `narrowing` test while telling a consumer nothing:

- `check-adr-0087-registration`: `narrowing` is in
`MUST_MATCH_BREAKING`, `widening` is in `MUST_NOT_MATCH_BREAKING`, both
on a banner-free `minor` changeset — the exact shape objectstack-ai#16296 shipped in.
- `check-changeset-no-major`: same tree, same levels, only the
declaration moves — `no (narrowing)` reaches `enforce`, bare `no`
reaches `not-declared`.
- `check-clause2-carriers`: both arms read; the contradiction, the
near-arm family and the unfilled menu are all `malformed`; and three
CONTROL rows pin the arm-less spellings unmoved.

## Two judgement calls, declared rather than made quietly

1. **The `**BREAKING**` banner signal is KEPT.** The dispatch word asked
for breaking-ness to stop being read from the prose banner. Removing
signal (2) would un-declare the **52 changesets in stock** that carry it
and nothing else — the gate's own docblock has refused that direction
("narrowing to any one of them would drop real declarations") since it
was written, and the ruling's own text asks only that the gate *read the
arm*. Signal (4) is added beside it, so breaking-ness no longer
**depends** on prose. If the maintainer wants (2) retired, that is a
second, deliberate card with its own stock count.
2. **`check-adr-0087-registration` reads the arm from the CHANGESET
body, not the PR body.** It has no pull-request payload by construction
— `cut-rc.yml` runs it on a `workflow_dispatch` over a whole snapshot
range with no PR at all. All three of its existing signals are
changeset-local, and the fourth is too.

## Acceptance notes

- noted, not filed: `readClause2Correction` does not carry the arm. A
correction comment can change a declaration's *value* but not its
*direction*. No half-state results today — the arm's three readers are
the changeset body (ADR-0087), the PR body (no-major) and this file's
own rows, and none of them reads corrections — so this is recorded
rather than filed. Carrier: `scripts/pm/check-clause2-carriers.mjs`, the
next card that touches `readClause2Correction`.
- noted, not filed: the I1/I2 fixture in
`check-adr-0087-registration.mjs` staged its import siblings from a
hand-written two-name list. Signal (4) added an edge into a nine-module
closure including `pm/dispatch-gates.mjs`, so the list is now
**derived** by walking real import statements. That is a guard extension
inside this card's own defect class, not a drive-by: without it, the
next import added over in `pm/` would kill this gate's fixture with an
error about neither file.

## 维护者速读(草稿)

**改了什么** —「本卡是否放宽契约」这条申报,现在可以再带一个方向:`(widening)` 或
`(narrowing)`,只有这两种拼法。声明 `(narrowing)` 的改动会被三个门禁当作破坏性变更处理:必须写 ADR-0087
处置、必须给到 `minor` 档。`AGENTS.md` 记下格式,行数一行没多。

**为什么改** — 一次「以后不再接受某些写法」的收紧,过去可以在发布说明里一个字都不提就发给客户,所有门禁照样亮绿:门禁是从作者手写的
`**BREAKING**` 横幅判断破坏性的,忘了写就当没有。objectstack-ai#16296objectstack-ai#14238
就是这么发出去的。改完之后,判据是一个封闭的词,不是一段散文。

**风险与代价(含回滚)** — 风险面是「在飞的申报会不会一夜失效」。方向词是可选的,板上 5 个在飞 PR
的申报实测全部逐字不变,已列在上面的表里。代价是作者多写一个词,并且要把同一行抄进 changeset
正文。两条已申报的判断:`**BREAKING**` 横幅这条旧信号保留(库存里 52 个 changeset 只靠它),以及 ADR-0087
门禁从 changeset 正文读方向(它跑 RC 切版时根本没有 PR)。回滚是单笔
revert:三个门禁各自的自测都是新增用例,没有既有用例被改写。

**席位意见** — (留空,复核席定稿)

**你要做的** — 确认两件事:① 旧的 `**BREAKING**`
横幅信号保留,是否合你意(裁定原文只说「读分肢」,派发词的转述是「不再从横幅判断」);② 两条 ADR-0087
账本行记的是平台对象列的取值域收紧 —— 该账本服务的是元数据升级者,请确认这两行落在那里是你要的记录位置。

---

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

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

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

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

---------

Co-authored-by: claude[bot] <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants