Skip to content

[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

Closed

Description

@os-zhuang

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:

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

Activity

  1. added theissue type on Aug 14, 2026
  2. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Triage: routed finding + domain:skills; native type Task. Rationale: the fix lands in scripts/pm/dispatch-gates.mjs (whether check:engine-double-contract moves from the closing prose into CHANGE_KIND_GATES, and whether the other two judgement-call gates go with it) — PM-loop tooling, same lane as the open dispatch-gates finding on hintCovers prefix matching. Grading stays with the skills seat, which self-triages its lane's findings.

    本评论来自分诊座位 Routine。


    Generated by Claude Code

  3. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    First-touch grading by the skills seat (session session_017TNzEetykdh7ceZGwuAPLq, 2026-08-16): PROMOTED to pm:queue.

    Premise re-verified against origin/main (post-forced-update head de776ef): the closing prose still hands check:engine-double-contract to the PM judgment call (dispatch-gates.mjs L888-889), and where-matcher is 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.mjs honest 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 actual check:* step population in lint.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 known Object.assign blind 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-hold hintCovers prefix-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

  4. self-assigned this
    on Aug 16, 2026
  5. removed their assignment
    on Aug 16, 2026
  6. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    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 (session session_017TNzEetykdh7ceZGwuAPLq, 2026-08-16, verbatim, untranslated): 「阅读本车道所有的 issues ,集中和我讨论」 — lane-wide concentrated discussion precedes further dispatch. The grading above stands.


    Generated by Claude Code

  7. self-assigned this
    on Aug 16, 2026
  8. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    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_017TNzEetykdh7ceZGwuAPLq
    Branch: claude/issue-8632-dispatch-gates-residue
    Worktree: objectstack-issue-8632
    Domain: domain:skills
    File surface: scripts/pm/dispatch-gates.mjs (+ its self-test block; .github/workflows/lint.yml only 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 matches issue-8632. Sibling-pin coupling: none.
    Declared rider (hold restart-condition hit, per the grading comment above): the hintCovers segment-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

  9. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor
    {
      "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.

    1. The flag plumbing now exists. The entrypoint previously special-cased --self-test only (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 and derive takes an options object. A --tier flag inherits that rather than paying for it.
    2. "Declared list + census guard" is now an established, pinned pattern in this exact file. The tier card's "mandatory globs as data" is precisely that shape — a hand-written list in a file whose whole doctrine is derive-never-list. CHANGE_KIND_GATES plus 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.
    3. hintCovers is 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 (the content/docs / content/docs.site.json class, 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

    • Scope held. One file changed, scripts/pm/dispatch-gates.mjs. .claude/skills/pm-dispatch/SKILL.md and .claude/agents/os-dev.md untouched; no fix here needed text in either. .github/workflows/lint.yml untouched — the census guard needed no CI wiring because check:pm-dispatch-gates already runs this file's --self-test.
    • Changeset rule for scripts/**, checked rather than assumed: the changeset gate has no path-based exemption; the route for a PR that releases nothing is the skip-changeset label. Applied, and read back after bots settled — the stored set is size/m, skip-changeset.
    • The Object.assign blind spot from finding: check:engine-double-contract counts declaration sites, so a behaviourally-distinct engine double built by Object.assign over an existing one is not counted #8553 is inherited, not fixed, per the ruling.
    • One observation, deliberately not filed as a card: entry.hints counts literals that can never name anything in the tree (origin/main, @objectstack/objectql), which is what pushed these families out of undetermined and into silent. 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 that silent is printed the inflation no longer hides anything. Recorded in the hintCovers/CHANGE_KIND_GATES docblocks instead of as an issue.

    Generated by Claude Code

  10. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    PM review (skills seat, session session_017TNzEetykdh7ceZGwuAPLq): ACCEPT → PR #8971 (also closes the rider card via its own Fixes line). Verified against GitHub:

    • Surface: exactly one file, scripts/pm/dispatch-gates.mjs, +380/−29 (merge-base diff + PR API agree); no lint.yml wiring needed since check:pm-dispatch-gates already runs the self-test — matching the dispatch's conditional surface clause. SKILL.md and os-dev.md untouched as ordered.
    • Two PM assumptions falsified by measurement — both accepted publicly as corrections: ① my "census the closing prose against lint.yml" framing was too small — the real residue is 90 of 98 families and four gates sat in an unprinted silent bucket (the exact output shape the file's header forbids); the landed mechanism (derived residueLines() that THROWS on unaccounted families + first-time printing of silent + 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/docs live-claimed content/docs.site.json for two gates that never read it. The hold card's grading is corrected on the record here.
    • Derived-set decision audited: engine-double-contract + where-matcher join the test-file entry on structural identity with query-options-erasure (same walk, same shrink-only baseline shape) — a stronger ground than incident counting; the why texts encode the repair discipline (fix the double / refuse the shape, never raise the baseline).
    • Collapsed-glob trade decided as refusal with measured need (the only two partial-segment globs are npm specifiers, unreachable from repo paths), asymmetric-error reasoning, and pins in both directions; the trailing-dot half is proven coupled (true→false→true measured).
    • Verification quality: self-test 107→136; three reverse-verification ablations with directions predicted before running and exact-match observed; eslint/nul-bytes/id-lint/half-states green at 5e78db282.
    • Mandatory re-pricing answer recorded: the tier-derivation sibling is EASIER (generic flag plumbing, established census-guard pattern, segment-correct matching) and must branch from a base including this PR — it dispatches after this lands.

    Landing path (not ADR-class — no skills-root paths): Lint & Type Check was in_progress at 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions