Repository navigation
[finding] check:engine-double-contract is a PM judgement call in dispatch-gates.mjs, not a convention entry — and a mechanical trigger for it exists #8632
Description
Filed unassigned, no pm:queue, for triage to route. Found while driving PR #8626 (#8577) to green; not a defect in that PR, which fixed the underlying problem at the doubles and is green.
What happened
PR #8626 went red on CI under the job named ESLint, on the step check:engine-double-contract:
x PINNED [delete]: …/suggested-audience-bindings-install-path.test.ts declares 2 engine
double(s) whose delete() does not route through assertEngineDeleteDispatch (lines 143, 337)
x PINNED [update]: … same two, assertEngineUpdateDispatch
The dev seat had derived its gate union with scripts/pm/dispatch-gates.mjs against the actual changed paths — twice, including a re-derivation after adding a vitest config — and check:engine-double-contract was in neither derivation.
Why it was not in the derivation — measured, not inferred
CHANGE_KIND_GATES declares exactly two change kinds:
- adds or edits a test file →
check:query-options-erasure+check:type-check-coverage+check:type-check-debt - edits a file in a package that owns an
i18n-extract.config.ts→check:i18n
check:engine-double-contract appears in the script only in its closing prose, which explicitly hands it to human judgement:
the rest stay the PM judgment call — new fake engine =>
check:engine-double-contract, new error code =>check:error-code-casing, any edit =>check:nul-bytes
So the derivation did not lose an entry it has — it has none, by design. This is worth stating plainly because the first reading (including mine) was "the tool missed it", which would have pointed at the wrong fix.
The other half of the miss was judgement: two delegating pass-through seams wrapping a real engine were not classified as "fake engines", which is exactly the call the prose delegates. That reading is defensible — they delegate to a real ObjectQL — and it is still the reading the gate disagrees with.
The question this raises
A mechanical trigger for this gate does appear to exist: a test file declaring an object literal with delete/update members is the shape check-engine-double-contract.mjs itself scans for. If that is a reliable enough signal, the gate could move from the prose (judgement) into CHANGE_KIND_GATES (derived), and this class of miss stops depending on whether the author recognises their own seam as a double.
Arguments the other way, which is why this is filed rather than fixed:
- the gate's own detector is more precise than a path-level convention could be, so a coarse trigger would emit the gate on test files that have no doubles at all — noise that trains seats to skip it;
- the prose is currently honest about the boundary. Moving one of the three judgement-call gates into the derived set without the other two (
check:error-code-casing,check:nul-bytes) leaves the prose describing a set it no longer matches; - finding:
check:engine-double-contractcounts declaration sites, so a behaviourally-distinct engine double built byObject.assignover an existing one is not counted #8553 already records that the detector counts declaration sites, so anObject.assign-built double is invisible to it — a convention entry keyed on the same shape inherits that blind spot.
Whoever takes this should decide whether the gate belongs in the derived set at all, and if so, whether the prose's other two go with it.
Related
- fix(plugin-security,service-messaging): scope two more tenant-scoped declared unique indexes per organization (#8577) #8626 / Two MORE clean inheritors of the #8323 class the #8554 sweep missed: sys_notification_subscription and sys_audience_binding_suggestion — both measured live #8577 — where it surfaced; fixed at the doubles, baseline untouched
dispatch-gates.mjsnamescheck:type-check-coveragefor a rationale onlycheck:type-check-debtsatisfies — the ratchet half never runs locally #8545 —dispatch-gates.mjsnamed the wrong ratchet invocation (fixed,375376456446)- A green gate union proves nothing about the head it is reported against — nothing ties the run to a commit, so a mid-round union goes stale silently #8550 — a green gate union proves nothing about the head it is reported against
- finding:
check:engine-double-contractcounts declaration sites, so a behaviourally-distinct engine double built byObject.assignover an existing one is not counted #8553 — the detector counts declaration sites, so anObject.assign-built double is invisible - No gate requires a breaking-behavior changeset to carry the breaking annotation at all — the ADR-0087 disposition gate only fires once one already does #8607 — a named gate that does not exist at all
Activity
hotlong commentedon Aug 14, 2026
Triage: routed
finding+domain:skills; native type Task. Rationale: the fix lands inscripts/pm/dispatch-gates.mjs(whethercheck:engine-double-contractmoves from the closing prose intoCHANGE_KIND_GATES, and whether the other two judgement-call gates go with it) — PM-loop tooling, same lane as the open dispatch-gates finding onhintCoversprefix matching. Grading stays with the skills seat, which self-triages its lane's findings.本评论来自分诊座位 Routine。
Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
First-touch grading by the skills seat (session
session_017TNzEetykdh7ceZGwuAPLq, 2026-08-16): PROMOTED topm:queue.Premise re-verified against
origin/main(post-forced-update headde776ef): the closing prose still handscheck:engine-double-contractto the PM judgment call (dispatch-gates.mjs L888-889), andwhere-matcheris still named nowhere in the file (grep count 0). Three measured instances now sit on this card, and the third proves the prose is not a census of its own residue.Scope of the deliverable (the dev decides the mechanism, the goal is fixed): make
dispatch-gates.mjshonest about its residue. Acceptable shapes, singly or combined: ① a derived-set entry for the test-file-with-engine-double shape (weighing the noise argument recorded on this card — instance 2 argues "no prompt to classify at all" beats noise); ② a census/sync guard that fails when the closing prose's residue enumeration drifts from the actualcheck:*step population inlint.yml(already out of sync by ≥1:check:where-matcher); ③ both. Constraints: script header stays the authority for its own details; self-tests extended for whatever changes; the detector's knownObject.assignblind spot is inherited, not fixed here. ⛔ Do not touch.claude/skills/pm-dispatch/SKILL.md(in flight under a sibling card) — the 机械守卫索引 one-liner already describes this script generically and needs no edit; if your fix seems to require SKILL.md text, stop and report.Serial note: the tier-derivation card (same file, promoted this round) is hard-serialized behind this card.
Rider (hold restart-condition hit): this dispatch touches
scripts/pm/dispatch-gates.mjs, which is the named trigger of the on-holdhintCoversprefix-boundary card — its fix (segment-boundary comparison both directions, with the collapsed-glob reach trade decided-not-assumed, pinned in both directions per the direction on that card) travels as a declared rider of this dispatch.Size/model: M (design judgment inside PM tooling),
mode:subagent,model: opus(not under a fable-mandatory root — verified against the globs, not memory).Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
Claim rolled back before dispatch (assign + label pair reverted to
pm:queue, no claim comment was ever posted — half-state cleanup). Provenance: maintainer instruction in the live PM session (sessionsession_017TNzEetykdh7ceZGwuAPLq, 2026-08-16, verbatim, untranslated): 「阅读本车道所有的 issues ,集中和我讨论」 — lane-wide concentrated discussion precedes further dispatch. The grading above stands.Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
Claim: PM loop round 2 (skills seat) — re-claim after the discussion rollback; dispatch resumed on the maintainer's batch acceptance (2026-08-16, verbatim: 「其他问题我接受你的建议。」).
Session:
session_017TNzEetykdh7ceZGwuAPLqBranch:
claude/issue-8632-dispatch-gates-residueWorktree:
objectstack-issue-8632Domain:
domain:skillsFile surface:
scripts/pm/dispatch-gates.mjs(+ its self-test block;.github/workflows/lint.ymlonly if a census-guard needs CI wiring) (stop on breach; explain in the report)Container & model: M,
mode:subagent,model: opus(not under a fable-mandatory root — checked against the globs)Serial constraints cleared: zero open PRs touch
scripts/pm/**(open-PR list re-read this round); the tier-derivation card is hard-serialized BEHIND this card (same file, not dispatched); no remote branch matchesissue-8632. Sibling-pin coupling: none.Declared rider (hold restart-condition hit, per the grading comment above): the
hintCoverssegment-boundary fix — both directions, the collapsed-glob reach trade decided explicitly (not assumed), pinned in both directions.Mandatory re-pricing question for the report: does your change make the tier-derivation sibling card easier, harder, unnecessary, or unaffected?
Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
{ "issue": 8632, "status": "done", "branch": "claude/issue-8632-dispatch-gates-residue", "pr": "https://github.com/objectstack-ai/objectstack/pull/8971", "premise_still_valid": true, "summary": "Premise re-verified at my head d6e80b28b (origin/main had advanced past the PM's de776ef, which is an ancestor): closing prose still hands three gates to the PM judgment call, where-matcher grep count still 0, hintCovers still raw-prefix. Took option 3 (both), but a measurement corrected the mechanism for each half. FALSIFIED the PM's framing of option 2: all four residue gates are not merely unlisted, they sit in a SILENT bucket the output never mentioned at all (55 of 98 families on a two-path card) - they have hints, so the undetermined bucket never sees them, and the hints cover nothing, so the matched column never sees them either. That is the one output shape the file's own header forbids. So the real residue is 90 families, not 3, and a prose enumeration held in sync against lint.yml was never available. Instead: the hand-written tail is deleted and replaced by residueLines(), which states the partition from runtime counts, names no gate, and THROWS when the three verdicts do not account for every discovered family; the silent bucket is printed for the first time; --residue lists the unplaced families runnably with the hints their exclusion rests on. Option 1 landed as a widened, evidence-backed version: check:engine-double-contract AND check:where-matcher joined CHANGE_KIND_GATES's test-file entry, decided not on the three incidents but on a structural identity with check:query-options-erasure already in that entry - all three walk the tree for *.test.* files and name only their own shrink-only baseline. The census guard that WAS available holds the one enumerable list in the file: every CHANGE_KIND_GATES name is now asserted against the live workflow population by the self-test, not only by the run that prints STALE. No lint.yml wiring needed - check:pm-dispatch-gates already runs this file's --self-test. Rider closes #8534 in both halves, and one half's premise also changed: the hintCovers sibling defect is NOT dormant. The hold card's census probed 73 package DIRECTORIES; re-measured against all 5940 tracked files, 'content/docs' live-claims the sibling FILE 'content/docs.site.json' for check:role-word and check:docs-audit-scope, neither of which reads it. Collapsed-glob reach trade DECIDED as a refusal (evidence: the only two live partial-segment globs are npm specifiers @objectstack/spec@* and @fx/spec*, unreachable from any repo path; one rule not two; refusal errs toward a missing lead rather than a fabricated one) and pinned in both directions. The trailing-dot half is not cosmetic but COUPLED: the boundary rule removes the exact branch through which the one live trailing-dot hint reached its own file, measured true -> false -> true.", "tests": "All at 5e78db282, the final commit, tree clean (verified before the union ran; reverse-verification ablations were all restored via git checkout, never git stash). `node scripts/pm/dispatch-gates.mjs --self-test` 136 cases pass, vs 107 on origin/main (+29). `pnpm check:pm-dispatch-gates` pass - the only path-matched family in the post-change re-derivation of my actual changed paths, which exercises this fix. `node scripts/check-nul-bytes.mjs --self-test` 75 assertions pass; `node scripts/check-nul-bytes.mjs` OK, 5935 text files, no control bytes; independent self-scan `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` clean. `npx eslint scripts/pm/dispatch-gates.mjs --no-inline-config` exit 0 (.mjs IS linted - eslint.config.mjs L583 base block covers mjs). check:pm-skill-id-lint, check:pm-half-states, check:ratchet-remedy-authority, check:partof-closing-keyword all pass. Type-check ratchets proven unable to move rather than skipped: no tsconfig in the tree sets allowJs, so a .mjs file is outside every tsc program. Heavy phase (pnpm install, eslint) serialized under flock -E 99 -w 540 /tmp/os-heavy-verify.lock; no queueing occurred. No ablation of a dist artifact was involved (pure scripts/ tooling, nothing built). Reverse verification, direction predicted BEFORE each run and restored after: (a) trailing-dot trim removed -> predicted 5 red including the live coupling pin -> observed exactly those 5; (b) segment rule reverted to raw prefix -> predicted 4 red (both sibling directions, the live content/docs specimen, the glob trade) with the boundary-glob cases staying green -> observed exactly those 4; (c) a table entry renamed to check:where-matcher-v2 -> predicted census guard red naming the missing gate while the live run still prints STALE -> observed both, guard message '(missing: check:where-matcher-v2)'. CI status at report time: in_progress - reporting at draft-PR time per contract, not waiting.", "open_questions": [], "out_of_scope_findings": [] }Mandatory report item: effect on the tier-derivation sibling card
Easier, on three counts, with one bounded caveat.
--self-testonly (argv.filter(a => a !== '--self-test')), so a second flag would have been consumed as an input path. It now filters every---prefixed argument generically andderivetakes an options object. A--tierflag inherits that rather than paying for it.CHANGE_KIND_GATESplus its new live-population guard is a working template for how such a list earns its place here: a stated deletion criterion, a STALE branch for the reader, and a self-test case that makes rot fail CI.hintCoversis segment-correct ahead of it. If tier derivation matches a file surface against globs like.claude/skills/*, the raw-prefix rule would have produced false tier escalations from sibling directories (thecontent/docs/content/docs.site.jsonclass, measured live above). That defect is now fixed underneath the tier card rather than waiting to be discovered by it.Caveat, not a cost if the existing serialization holds: this is +380/−29 in the same file, touching the header,
hintCovers,extractWatchHints,CHANGE_KIND_GATES,derive's tail, the entrypoint, and ~29 new self-test cases. The tier card must branch from a base that includes PR #8971; branched from an earlier base it conflicts broadly. The card is already hard-serialized behind this one, so this is a note to keep that serialization, not a new constraint.Not unnecessary: nothing here answers which model tier a file surface implies — that stays an orthogonal question.
Notes for the PM
scripts/pm/dispatch-gates.mjs..claude/skills/pm-dispatch/SKILL.mdand.claude/agents/os-dev.mduntouched; no fix here needed text in either..github/workflows/lint.ymluntouched — the census guard needed no CI wiring becausecheck:pm-dispatch-gatesalready runs this file's--self-test.scripts/**, checked rather than assumed: the changeset gate has no path-based exemption; the route for a PR that releases nothing is theskip-changesetlabel. Applied, and read back after bots settled — the stored set issize/m,skip-changeset.Object.assignblind spot from finding:check:engine-double-contractcounts declaration sites, so a behaviourally-distinct engine double built byObject.assignover an existing one is not counted #8553 is inherited, not fixed, per the ruling.entry.hintscounts literals that can never name anything in the tree (origin/main,@objectstack/objectql), which is what pushed these families out ofundeterminedand intosilent. I did not filter them — it changes no verdict today (each affected family retains a real baseline-path hint and stays silent either way), and now thatsilentis printed the inflation no longer hides anything. Recorded in thehintCovers/CHANGE_KIND_GATESdocblocks instead of as an issue.Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
PM review (skills seat, session
session_017TNzEetykdh7ceZGwuAPLq): ACCEPT → PR #8971 (also closes the rider card via its ownFixesline). Verified against GitHub:scripts/pm/dispatch-gates.mjs, +380/−29 (merge-base diff + PR API agree); nolint.ymlwiring needed sincecheck:pm-dispatch-gatesalready runs the self-test — matching the dispatch's conditional surface clause. SKILL.md and os-dev.md untouched as ordered.silentbucket (the exact output shape the file's header forbids); the landed mechanism (derivedresidueLines()that THROWS on unaccounted families + first-time printing ofsilent+ runnable--residue) is strictly stronger than what I asked for. ② The rider's "dormant" premise was refuted by widening the census from 73 package directories to all 5940 tracked files:content/docslive-claimedcontent/docs.site.jsonfor two gates that never read it. The hold card's grading is corrected on the record here.whytexts encode the repair discipline (fix the double / refuse the shape, never raise the baseline).5e78db282.Landing path (not ADR-class — no skills-root paths): Lint & Type Check was
in_progressat review time; a timed flip check will re-read the gate-job conclusions and, on green, mark ready + arm auto-merge through the queue. Label cleanup on both cards at MERGED.Generated by Claude Code
fix(pm): make dispatch-gates honest about its residue, and match hint…