Skip to content

pm gates: refuse a widening diff whose claim declares Clause-②: no (the mechanical half of the directional ruling) - #16604

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-16448-clause2-widening-tell
Sep 7, 2026
Merged

pm gates: refuse a widening diff whose claim declares Clause-②: no (the mechanical half of the directional ruling)#16604
os-musk merged 4 commits into
mainfrom
claude/issue-16448-clause2-widening-tell

Conversation

@os-musk

@os-musk os-musk commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Part of #16448

The mechanical half of the directional clause-② ruling (#16349): a diff that
adds a schema key, a closed-set member, a published export row or a registry
entry, while its card's claim declares Clause-②: no, is refused at enqueue with
the file:line of every tell.

What lands

scripts/pm/check-widening-tells.mjs (new) — the tells, the surfaces, the
refusal sentence and a 131-case --self-test. Pure over its input; its whole
input is a diff, supplied by its caller.

scripts/pm/check-clause2-carriers.mjs — a fifth row, C5, on the
--pair predicate. That is the enqueue path: references/contract-review.md
landing pre-check ② already runs --pair PR-NUMBER before a seat may hand a
pair to the queue. C5 joins the declaration this file already reads to the diff
the sibling judges; the tells themselves are stated once, in the sibling.

package.json / .github/workflows/lint.ymlcheck:pm-widening-tells,
the self-test wired the way this directory's other gates wire theirs
(check:self-test-wired green over the new step).

The four tells, calibrated against the tree rather than guessed

tell shape surface, and where the surface is declared
T1 a property whose value is a schema SUSPECT_TIER_GLOBS, imported from dispatch-gates.mjs
T2 a member of z.enum / z.union / an as const array same
T3 a row of a published entry point's export listing derived from REGEN_ARTIFACTS rows whose check is check:api-surface
T4 a registration in a registry / catalog the one table this gate declares itself, existence-guarded in its self-test

T1's recogniser was measured over packages/spec/src/** before it was written:
8,102 property lines take a z. value, and the whole non-z. schema vocabulary
under them is retiredKey( (235), the *Schema identifiers (about 300),
strictObject( (46) and lazySchema(. Requiring a schema-shaped value is what
keeps the tell off the 1,655 x: true and 1,170 x: string lines in the same
files, which are object literals and type annotations, not accept-set members.

Two boundaries this PR keeps

  • No new label, no new claim-line syntax. The reader is the sibling's
    existing Clause-②: reader; this gate writes nothing and hangs nothing —
    hanging a review gate from a checker would be issuing the verdict.
  • --pair only, never the sweep. A no pair costs one extra request (its
    changed-file listing). Paying that per sweep pair would push a 29-PR sweep past
    the anonymous hourly budget it already sits on, and a widening tell on somebody
    else's pair is a board fact, not a verdict about the PR that runs CI next —
    the same split every other row in that file already makes.

Where judgement was exercised, along the four axes

Home: a sibling script, not a function inside dispatch-gates.mjs.
Real need — measured: --pair has no workflow caller at all; it is a seat's
pre-arm predicate, and dispatch-gates.mjs is 20k lines that check-clause2-carriers
does not import today. Long-term — a leaf module keeps the dependency acyclic
(the sibling imports the leaf; the exit-code pin is written on the importing
side). AI-error — the tells get their own self-test and their own battery
floors, so a weakened recogniser reddens something. Startup focus — no new
surface: one file, one package script, one CI step.

Exit 4 reused, not a new code. Rows already carry codes (C1..C5); the exit
carries one bit, "a verdict about this pair, adverse". A fifth exit would make a
seat reading $? maintain two tables for one decision.

A registry table declared by hand, with an existence guard. No register in
the tree answers "which files are closed-vocabulary registries", so this one is
declared here — and pinned to paths that exist, the way MANDATORY_TIER_GLOBS is,
because a declared-but-absent glob is dead data that guards nothing while reading
as protection.

Patch round 1 — an unread diff was reading as a narrow one

The seat's contract-tier review found the gate breaking its own contract. The
local diff splitter wrote additions: addedLines(patch).length for every
row, and addedLines(null) is empty — so a binary change stamped a count
nobody had taken. unreadFiles skips a row that added nothing, so a binary edit
to packages/spec/api-surface/kernel.json arriving through the local path
reported state: 'clean'. Declared and not enforced, inside the gate whose whole
contract is an unread diff is not a narrow diff.

The three states are now told apart by what the diff says:

what git emitted additions reading
a hunk the count, taken from it judged
Binary files … differ / GIT binary patch null UNKNOWN — a gap on a tell surface
neither (mode-only, pure rename) 0 really added nothing — clean

addedNothing interprets both input paths in one place, so a missing count
can never become a zero. The API path keeps GitHub's own additions: 0 on a
binary row — that reading was taken by something that can see the blob, so the
two paths differ by information available, not by drift; the splitter's
docblock says so.

The self-test had pinned the two halves separately (patch: null, and "null is
a gap" on a hand-built row carrying no additions) and never composed them,
which is why it stayed green. Seven composed cases now drive
splitUnifiedDiff into wideningRefusal, and four more pin the additions
readings themselves.

Measured

At head 9f33c3a3:

  • node scripts/pm/check-widening-tells.mjs --self-test0, 131 cases
    (120 before the patch round).
  • node scripts/pm/check-clause2-carriers.mjs --self-test0, 209 cases
    (was 190; +16 for the C5 join, +3 for the exit-register pin).
  • Ablation 3 — the patch's own cases catch the reported defect. The fixed
    expression replaced by the original addedLines(patch).length. On disk: blob
    d951ee5b to 70da08e1, marker present 1, fixed expression present 0.
    Mutated self-test exit 1, 5 failing cases — the two additions pins and
    the three composed ones, including a BINARY change to a tell surface reads INCOMPLETE, never clean. Restored to d951ee5b, git diff HEAD empty,
    self-test back to 0.
  • Ablation 1 — the tells can fail. SCHEMA_PROPERTY replaced by a
    never-matching pattern. On disk: blob 40decab9 to b49d87cf, marker present
    1, original opener present 0. Mutated self-test exit 1, 14 failing cases.
    Restored to 40decab9, git diff HEAD empty, self-test back to 0.
  • Ablation 2 — the wiring can fail. needsWideningRead forced to false.
    On disk: blob 71d7d605 to 74687b76, marker present 1, original guard
    present 0. Mutated sibling self-test exit 1, 7 failing cases. Restored to
    71d7d605, git diff HEAD empty, self-test back to 0.
    Ablation 1 deliberately leaves the sibling green: its C5 battery drives a T2
    tell, so ablating T1 must not move it. Two instruments, two populations.
  • Full repo lint, through the shared verify lock:
    OS_VERIFY_LOCK_SLOT=issue-16448 bash scripts/pm/os-verify-lock.sh -c "pnpm lint"
    printed VERDICT command-exit 0, 76s held. eslint over the two touched files
    with --format json — 0 errors, 0 warnings; a full-tree
    eslint . --no-inline-config --format json earlier in the round counted
    6,286 files, 0 errors, 0 warnings, i.e. the whole population eslint's own
    config selects.
  • Derived gate sweep re-run at 9f33c3a3,
    node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
    (61 commands): 58 exit 0. Two exit 3, PREREQUISITE NOT MET and are NOT
    MEASURED — check:dual-build-cjs-loads and check:type-check-debt read built
    dist/ and refuse without pnpm build; this diff touches no file under
    packages/, so neither can move, and CI runs them on a built tree.
    check:pm-dispatch-gates did not finish in a 300s foreground budget — and
    does not finish on a pristine checkout either (control run with none of
    these changes: 540s wall, 1,522 assertions printed, zero failures). A
    pre-existing runtime on this box, not a regression; CI owns it.
  • Reconciliation: node scripts/pm/dispatch-gates.mjs --ran at 9f33c3a3
    61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN.
  • node scripts/pm/check-governed-merges.mjs --test over this PR's exact file
    list — exit 0, 0 of 4 path(s) hit the register: ordinary queue landing.
  • Control-character self-scan over every touched file — clean;
    pnpm check:nul-bytes — 0.
  • Merge probe: git merge-tree --write-tree --name-only origin/main HEAD
    exit 0, clean against a7c14cdf.

skip-changeset: this PR publishes nothing from any package — scripts/pm/**,
two package.json script entries and one workflow step.

验收备注

The one line in .claude/skills/pm-dispatch/references/contract-review.md that
names this gate ships as a separate PR (#16605) — that file is on the
governed surface (check-governed-merges --test returns exit 3 for it), so it
takes the human-merge route and must not ride a queue-landing PR.

Out-of-scope observations, noted and not filed:

  • The card's tell list names a renderer registry; this repo has none —
    RendererRegistry appears only in ADR-0012's notification-platform table. The
    live shape nearest to it is METADATA_FORM_REGISTRY, whose own docblock calls
    it the registry the generic SchemaForm renderer reads, so that is the row
    declared. Recorded in the gate's header. Successor: whoever ports this gate to
    a repo that has one. Not filed — there is no defect here, only an absent
    subject.
  • objectui's mirror of the contract schemas is packages/types/src/zod/**. The
    row is declared and repo-keyed so a port is a data edit, but nothing runs
    this gate in objectui today
    . Successor: none today; the row is inert by
    construction and its inertness is asserted in the self-test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX
`splitUnifiedDiff` wrote `additions: addedLines(patch).length` for every row,
and `addedLines(null)` is empty — so a BINARY change stamped a count nobody
took. `unreadFiles` skips a row that added nothing, so a binary edit to
`packages/spec/api-surface/*.json` arriving through the local path reported
`state: 'clean'`: the gate's own contract, declared and not enforced, inside
the gate.

The three states are now told apart by what the diff SAYS — a hunk gives the
count, a `Binary files`/`GIT binary patch` marker gives `null` (UNKNOWN), and
neither gives a real `0` (a mode-only change or a pure rename adds no line).
`addedNothing` interprets both input paths in one place, so a MISSING count can
never become a zero; GitHub's own `additions: 0` on a binary row is kept,
because that reading was taken by something that can see the blob.

The self-test pinned the two halves separately and never composed them, which
is why it stayed green. Seven composed cases now drive splitUnifiedDiff into
wideningRefusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX
…nnot honour

The gate's CI command is its own --self-test, so on the marker's own second
listed cause the declaration read true. dispatch-gates' live guard refuses it
anyway the moment a family NAMES paths, and this one names 59: 9 from its module
body (the registry table, the objectui mirror glob, two repo slugs, four fixture
filenames) and 50 inherited from the two registers it imports on purpose,
SUSPECT_TIER_GLOBS and REGEN_ARTIFACTS.

Getting those out of a scanned position would mean hand-copying two registers
this file imports precisely so it can never disagree with them - the drift
check:pm-governed-prose exists to stop, and this gate's strongest property. So
the marker goes and the derivation stands. The header now records what the
derivation says, which part of it is right (packages/spec/src/**,
api-surface/**, the three registries - the surfaces this gate polices) and which
part is noise inherited from REGEN_ARTIFACTS, and why one cheap self-test in a
MATCHED column is the smaller error than a marker sitting above a live
population.

The case that catches this sits at ~1534 of dispatch-gates' self-test and needs
well over 540s to reach, which is why three local runs missed it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants