fix(pm): decide an inherited population per CALLER, off what the import binds - #18157
Conversation
…rt binds `inherited-population` is keyed on the MODULE, while whether a contribution is fabricated is a property of the CALLER: a gate that binds one exported string constant out of `scripts/pm/dispatch-gates.mjs` inherited that module's whole declared watch surface and was told to run for every card touching the workflow directory it never opens. `firstPartyImportBindings` carries the binding beside each resolved target (the resolution and its order are unchanged to the byte), and `importBindsNoPopulation` reads it: an importer binding only primitive value constants reaches none of the module's reads and inherits nothing, while a table, a function, a class, a namespace, a default, a re-export and every clause this reading cannot parse inherit exactly what they did. A string constant whose own text is one of the module's paths stays population- bearing — binding a one-path population is reading it. Measured over the live tree: 1 of 52 followed import edges answers inert, and exactly 1 of 307 families' derived population moves. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 2433/2444, then In-seat record (non-governed surface: ① Derived judgments
② Semver levelNone. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…at has one `check-clause2-carriers --pair` read the `Served-tier:` provenance line only beside a gate clear: `reviewOfRecord` answered `not-owed` before any thread was read unless the pair was in C6's completed state, so C7 — the row that carries `references/contract-review.md`'s 「无此行不成裁决」 — was consulted on one path of two. Measured on one board in one day, one spelling: record 5661052272 (PR #18157 / card #17991, non-gated) carried a `Served-tier:` line this file's own reader answers `unreadable` for and `--pair` exited 0, while the same spelling on objectui PR #9486 was refused exit 4, because that pair's gate had been hung and cleared. Split the reader in two: `locateReviewOfRecord` finds the record on the head with no reference to the gate, and `reviewOfRecord` is that locator under C6's population gate. C7 and the C6-RECORD note read the locator; C6's row, its 「not-owed」 scope, the accept set, the exactness and the remedy are unmoved. The `--pair` path buys the PR thread for every pair (`landingReads`), the sweep does not, and `locatedRecordUnjudged` is the landing path's own #4690 half so an unread thread is UNJUDGED rather than clean. Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr Co-authored-by: Claude <noreply@anthropic.com>
… that has one (objectstack-ai#18251) Fixes objectstack-ai#18174 `check-clause2-carriers --pair` read a review of record's `Served-tier:` line only beside a gate clear, so `references/contract-review.md` :29's 「无此行不成裁决」 was enforced on one path of two. ## The defect, measured One spelling, two answers, same script, same day: | pair | gate hung? | the record's `Served-tier:` | `--pair` | |---|---|---|---| | objectstack PR objectstack-ai#18157 / card objectstack-ai#17991 (record 5661052272) | no — `Clause-②` never declared `yes` | a stamp control, then prose, then the constant — unreadable to this file's own reader | **0** | | objectui PR objectstack-ai#9486 / card objectstack-ai#9191 (record 5662548425) | hung and cleared | the same spelling | **4**, C7 | The seam: `reviewOfRecord` answered `not-owed` before reading a thread unless the pair was in C6's completed state (`needsRecordRead` → `gateBindingState(pair).state === 'completed'`), and `c7ServedTierBelow` returned `null` for anything but `found`. Measured on this branch's base `b3b43b6`: a non-gated pair carrying an unreadable record, one carrying the live shape, and one carrying no record at all were indistinguishable — `pairRows` empty for all three, `reviewOfRecord` `not-owed` for all three. ## The change - **`locateReviewOfRecord(pair)`** — the gate-independent locator: the same imported H51 heading / head-sha recognition, the same `Reviewed-by:` third fact, the same newest-governs choice, with no reference to the gate. No recognition moved. - **`reviewOfRecord(pair)`** — that locator under C6's population gate. C6's row, its 「not-owed」 scope, its shape sentence and its remedy are unmoved, and both rows still read ONE comment chosen once. - **C7** and the **C6-RECORD note** read the locator. C7's accept set (one token, the constant's NAME), its exactness, its refusal of a missing line and its remedy are untouched — widened in POPULATION only, in the direction its own rule text names. Its opening sentence is now gate-aware: it names a clear only where a clear rides on the record. The note's citation half stays C6's, because the act it names is C6's. - **`gather`'s `landingReads`** (was `readFiles`) buys the PR thread for every pair on the `--pair` path, where the ruling puts this reading (「清标前」); the sweep's population is unchanged, for the budget reason C5's identical split already records. **`locatedRecordUnjudged`** is that path's own objectstack-ai#4690 half, so an unread thread is UNJUDGED and never clean. - **Consequence pinned, not left to be discovered:** with the PR thread in hand, `verdictThreadRows` hands C4 a thread it previously saw only on completed pairs, so a verdict on a non-gated or still-hung pair is now judged for the independence pair it declares. That direction only adds verdicts to a reading whose newest-governs rule already lets a later independent verdict displace an older self-review. Pinned in both directions; the sweep's blindness there is restated as the limit that survives. ## Evidence **Reproduction on the real historical pair, replayed offline.** Both PRs are merged, so `--pair` cannot form the pair from the live board; the document carries the fetched `/pulls/18157`, `objectstack-ai/issues/17991`, both comment threads and the file list, and nothing else. - before (`origin/main` `b3b43b6`): `--pair 18157 --pair-json …` → exit **0** - after: exit **4** — C7 naming comment 5661052272, the unreadable line quoted back, and its stamp control read as NOT total (the 「回退证据」 half) **Live control**, PR objectstack-ai#18243 / card objectstack-ai#18229 — open, non-gated, `Clause-②: no`, record 5673963268 in the template's shape: exit **0** before and after. After, the run also prints the C6-RECORD note naming that record and saying the pair owes no clear (4 reads → 5). **The sweep is unmoved, deterministically:** the same document in sweep mode produces byte-identical `--json` output before and after. A live gated pair (objectstack-ai#18212) reads exit 4 with the same rows on both. **Self-test:** 629 → 658 cases, `pnpm check:pm-clause2-carriers` exit 0. A new battery (`objectstack-ai#18174: …`, 28 cases) carries the measured non-gated spelling beside the live one; the roster floor moves 22 → 23 and C7's own floor 42 → 43. Four of C7's population pins were re-triaged rather than left green for the wrong reason: each now spells a pair whose threads were READ and carry no record, instead of passing because no thread was ever fetched. **Reverse verification**, from the committed fix: `c7ServedTierBelow`'s locator call mutated back to `reviewOfRecord`. On-disk proof — injected marker grep = 1, deleted anchor grep = 0, blob hash `5a79776b` → `c4757e7a`. Self-test under the ablation: **5 of 658 red** (the measured pair, its two sentence pins, the still-hung pair and the 重挂 pair). Restored with `git checkout HEAD --`: `git diff HEAD` empty and the blob back at `5a79776b`. Direction: turns red, as predicted. **Gates**, union re-run at `7af15586` (`git rev-parse --short HEAD`), working tree clean: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derives **35** families; all 35 ran in the foreground with exits captured by redirect, all **exit 0**; `--ran` reconciles **35 derived / 35 run / 0 NOT-MEASURED / 0 UNRUN**, every exit code recorded. Beyond the derivation: `pnpm lint` (repo-wide, `eslint . --no-inline-config`) exit 0, and the two siblings that import this module — `check-governed-queue-guard.mjs --self-test` (238 cases) and `check-half-states.mjs --self-test` (4042 cases) — exit 0. `skip-changeset`: `scripts/pm/**` is repo tooling that no package `files[]` ships. ## Acceptance notes - noted, not filed: in a SWEEP a record on a non-gated pair stays invisible, because the sweep buys no PR thread for it. That is a declared limit, written into this file's budget paragraph beside C5's identical one, not a defect — and 承接者: nobody today; it becomes a card only if a seat ever prices a per-pair sweep thread. - noted, not filed: an existing pre-fetched `--pair-json` document that omits the `comments` entry for the PR now reads UNJUDGED (exit 2) on a pair that owes no record, where it read 0 before. That is this change's own documented owing, restated in the doc-shape paragraph, not a separate finding. 承接者: the MCP-only seats that hand this file a document — the file's own refusal text names the key to add. - The C4 reach on the landing path widens as a consequence of the bought thread (see above). In scope, documented and pinned in both directions. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17991
inherited-populationis keyed on the MODULE, while whether a contribution is fabricated is a property of the CALLER.scripts/pm/check-clause2-carriers.mjsimports one exported string constant (CONTRACT_REVIEW_TIER) fromscripts/pm/dispatch-gates.mjsand thereby inherited that module's whole declared watch surface, so every card touching.github/workflows/**was told to runpnpm check:pm-clause2-carriers— a gate that never opens that directory. No per-module declaration can express the refusal, because the same module's globs ARE a real population forcheck:pm-widening-tells, which binds and reads them.What changed
One file:
scripts/pm/dispatch-gates.mjs.firstPartyImportBindingsis the resolverfirstPartyImportTargetsalready was, carrying the one thing that function dropped: WHAT the importer binds from each module it reaches. The resolution, its refusals and its sort order are unchanged to the byte —firstPartyImportTargetsis now akeys()of it, and the whole-tree derivation was proved identical across that refactor before any rule was added.exportedValueConstantreads the exporter half: is NAME declared in that module as aconstinitialised to one primitive literal? It is read in ONE direction only (a positive answer removes leads), so every shape it cannot read answers negative — an array, an object, a function, a class, a computed or multi-line initialiser, a re-exported binding and an undeclared name are all "not a value".importBindsNoPopulationis the per-caller rule: an importer binding only value constants reaches none of the module's reads and inherits nothing; a table, a function, a class, a namespace, a default, a re-export and every clause this reading cannot parse inherit exactly what they did. One exception, and it is a live specimen rather than a hypothetical: a string constant whose own text is one of the module's paths stays population-bearing —scripts/adr-anchors.mjsexports its anchor directory that way, and binding a one-path population is reading it.discoverFamilieskeepsentry.importswhole (the edge is real) and records the contributing subset asentry.populationImports, unioned across the family's files.The two docblocks the card quotes are extended in place rather than a new marker being added:
declaredInheritedPopulationnow states what it is keyed on and what reaches the caller instead, and the follow's docblock states that reaching a module and inheriting from it are two questions. #11556's semantics for table importers are untouched, and.github/workflows/**is untouched.Acceptance
All readings on this branch at
1b75a853a0, base295eae57d.The card's ablation, reproduced on the committed base (mutate, measure, restore, restore proved by blob hash
e59aaff8e0698af16cc20bd2128b81b6bc211c5awithgit status --porcelainempty):And reversed on the head — the same ablation, same restore proof, now with no effect at all:
The derivation a dispatch prompt pastes.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack .github/workflows/lint.yml, base against head, is exactly one line shorter:and for
scripts/pm/check-clause2-carriers.mjsthe two command lists are byte-identical — the gate's own identity key is untouched, and a card editing the carrier checker still runs it.The control population (
check:pm-widening-tells) still inherits. It bindsSUSPECT_TIER_GLOBS(a table) andhintCovers(a function) from the same module, keeps.github/workflowswithhintOriginscripts/pm/dispatch-gates.mjs, and keeps all 50 of its inherited hints (49 of them fromscripts/regen-artifacts.mjs). The derivation's own line, from the self-test:The family-population diff for the whole tree. Every family's
hints,importsandhintOrigindumped before and after:Measured the same way over every live import edge the derivation follows: 1 of 52 edges binds only value constants, and it is the card's. The other four importers of this module are unmoved —
check:pm-widening-tellsbinds a table, andcheck:declared-population-live,check:watch-hint-literaland the two-hop cases bind functions. Note the card's suggested enumeration (git grep "from './dispatch-gates.mjs'") is incomplete: it misses the./pm/dispatch-gates.mjsspelling two of those importers use, so the enumeration here is taken from the derivation rather than from the grep.Gates. Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths, three-dot against merge base295eae57d, change set = 1 path), every one run in the foreground with$?captured before any pipe, then reconciled:All 31 exited 0, plus two run beyond the union because this change moves them:
pnpm check:pm-clause2-carriersandpnpm check:pm-widening-tells, both exit 0.pnpm check:declared-population-live(its self-test and then the live sweep) exits 0 — the #18138 lesson was measured rather than assumed: the wording added here adds no path-shaped literal any family declares, and the whole-tree dump above is the check.The self-test.
pnpm check:pm-dispatch-gates: 1723 cases pass, 0 failures, 0 NOT MEASURED subjects. Elevent(call sites added and zero removed (git diffcounts), so the count strictly increased; 1712 before is arithmetic off that diff, 1723 after is the measured run. The new cases pin both directions on fixtures (the clause reader, the exporter recogniser, the rule) and then the live shape:The self-test's own reconstruction of a family's hints now models the binding rule too, so a future widening of the recogniser reds there rather than silently agreeing with itself.
Restart-touch
Hold #14290 (
pm:on-hold, "dispatch-gates: STAGE-THEN-RUN reaches a program by an edge neither follow traverses") carries aRestart-touch:onscripts/pm/dispatch-gates.mjs, so this landing fires its re-verification. This change does not touch STAGE-THEN-RUN edges: it changes only what an IMPORT edge contributes, and leavesspawnedProgramTargets,readProgramTargetsInSourceandpackageManifestTargets— the follows #14290 is about — untouched in both traversal and population.Changeset
None.
scripts/pm/**publishes nothing — theskip-changesetlabel is applied.Acceptance notes
scripts/pm/dispatch-gates.mjsdoes not derivecheck:pm-clause2-carrierstoday either, measured on the base — the import edge's only contribution to that family was the fabricated workflow lead. An IDENTITY key over import edges is deliberately refused ([finding] dispatch-gates never names a family for an edit to a first-party module its gate script IMPORTS — 228 (family, module) pairs unreached, measured #13126, priced in the derivation's own self-test), so this is a recorded disposition rather than a gap this PR opens. Successor: none.entry.selfTestis still read off the WORKFLOW argv while a--self-testin apackage.jsonscript body is invisible to it, so the cheaper guard beside the fix stays silent for everypnpm check:*family whose self-test-only-ness is spelled in the manifest. The card offers it as a separable shape and the machinery for it already exists next door (selfTestOnlyInvocationresolves a row one hop through the manifest that defines it). It is left out here because it would move other families' derivation, which is the one thing this PR's blast radius is pinned against. Successor: the card, which stays open on this half if the seat wants it.Generated by Claude Code