Skip to content

ISS-13846: Accept real runner evidence in mutation-proof gate - #422

Merged
shafty023 merged 26 commits into
mainfrom
vibe/iss-13846-mutation-proofs
Nov 10, 2026
Merged

shafty023 merged 26 commits into
mainfrom
vibe/iss-13846-mutation-proofs

Conversation

@shafty023

Copy link
Copy Markdown
Collaborator

Fixes ISS-13846. The handoff mutation-proof gate refused real runner evidence: genuine Storybook failures, package-relative Vitest coordinates and a truthful remote-tier Desktop carry. That blocked ISS-13699 even after exact-head validation and genuine mutation proofs. This change makes the gate accept that evidence while tightening what counts as proof. vibe 1.1.75.

Vibe gate override: ISS-13900: the installed vibe 1.1.71 handoff gate refuses this PR only on prEvidence (ISS-13900 defect 9, the claude-plugins evidence schema). All four review receipts pass, and every other blocking gate is true (forbiddenPaths, hasChanges, storiesInV2Taxonomy, existingStoryTitlesKept, diffRulesClear, prBody). The 110 evidence problems are 91 candidates read from assertion text inside this PR's own test fixture strings, which main's candidate scan also reports, and 19 symphony-alpha evidence fields this repository cannot produce: e2e by-reference selections, touchFlows, the workflow lane report, the E2E capability probe, and exact-SHA targeted runs for its node test files.

What changed

AC-1 and AC-2: real runner failures bind the exact candidate (plugins/vibe/skills/handoff/scripts/test-integrity.mjs)

  • A Storybook runner failure at a changed .stories.tsx primary assertion now satisfies its candidate. That includes the browser-prefixed FAIL header, the play helper an exported story calls, and the instrumenter's source-map coordinate after a verified adjacent mutation.
  • Package-relative Vitest paths and matcher or multiline coordinates, and Playwright output, are matched against the repo-relative candidate. Path, line, column and assertion identity are all still checked.
  • Every absence proof binds a private raw runner receipt: command, exact head, mutation and restored exits, before and mutated source hashes, the restored output hash, and the one mutation edit, which is checked against the tested source.
  • Tightened in the same change:
    • a mutation that only changes comments or whitespace, including a JSX {/* */} comment, is refused;
    • authored text that only names the coordinate is refused, because only the runner's own failure frame binds the assertion;
    • a setup error, an unrelated assertion and an earlier assertion on the same line are still refused;
    • a direct Storybook frame on another assertion's line, or inside another multiline assertion when the runner owns the frame, is refused, while a frame on the story object or a helper call still maps;
    • a runner frame strictly inside the candidate's own expect(...) argument (a nested check, a waitFor callback or an imported helper call) is refused, and so is a frame on another assertion that shares a multiline candidate's closing line; the matcher line and the expect start still bind;
    • an absence proof whose test source the reader cannot read (a file over the bound or not a regular file) is refused, even when its receipt mutates a readable production file;
    • the runner's FAIL ... > Story Name header must name exactly the candidate's story, not a sibling whose name it prefixes;
    • on the instrumenter path, a later same-matcher assertion in the block that could also see the mutation makes the proof ambiguous and is refused: the same variable, a query (string in any quote style, or regular expression) equal to the candidate's or found in the mutation's inserted text, or a later call to a helper in the file that asserts with the same matcher.
  • Candidate classification: a line that holds only positive presence assertions such as await expect(idle).not.toBeNull() is no longer an absence candidate, unless that line holds only the assertion's argument or its argument there nests another check or a callback (otherwise main's line fallback is unchanged), and a count whose own assertion names a busy or loading state is no longer card-layout proof (decision PC-2, accepted by the orchestrator); a negated busy predicate (busy: false, aria-busy="false", :not([aria-busy]), !card.loading, card.loading === false, card.status !== "loading", card.dataset.busy === "false") still counts ordinary cards and keeps the layout check. Real absent-element and card-layout assertions still need their proofs, including a count whose card or summary comes only from its test context.

AC-3: remote-tier Desktop carry, one decision for inventory and comment (e2e-tier.mjs, pr-facts.mjs, pr-evidence-comment.mjs)

  • On a selected remote tier, a Desktop E2E spec is carried to CI as not run only when all of these agree: a live probe of the selected runner at the exact head (read-only, through its e2e-slot.sh route) finds no supported displayless Electron route (the commit must be a full SHA, so evidence text never reaches the remote shell); the selected lane and spec record match; and the post-merge desktop-e2e/validated owner is named. A runner whose native Desktop slot executes keeps the spec required. Fabricated, caller-hashed and stale probes fail.
  • The handoff inventory and the PR evidence comment call one validation (prEvidenceValidationProblems) and one proof-candidate base (proofCorpusBase). The comment previously hard-coded origin/main.
  • agents/vibe-verify-worker.md documents the remote-tier record fields the validator reads.

AC-4: credential-free web E2E proof route (skills/handoff/SKILL.md)

  • A web E2E mutation proof without the staging test password can run in a repository-owned credential-free Playwright project that collects the spec with no live sign-in setup. The proof requirement is unchanged, and no worker handles a credential.

AC-5: real ISS-13699 evidence. Run against the real ISS-13699 exact-head checkout (5afa61fa) and its private evidence, the pull-request inventory returns ok: true with all seven blocking gates true and zero evidence or body problems. The evidence comment exits 0. The ticket's reproduction of the same inventory on installed 1.1.61 failed prEvidence with 26 problems.

pr-evidence-schema.mjs takes the worktree and checks proofs against the tested source.

Main merge (#418, vibe-core). #418 moved tested-head.mjs and session-record.mjs into plugins/vibe-core/lib and loads them through shared/vibe-core.mjs. This branch's own imports of them in pr-facts.mjs, pr-evidence-schema.mjs, pr-evidence-comment.mjs and pr-evidence-comment.test.mjs now use that loader, with the same names; no file this branch changes was moved.

Not in this PR

  • The PR-create guard (head-branch scoping and the known-gate-bug override) is ISS-13855's scope. The guard edits an earlier writer made on this branch were removed, so the hooks (now in plugins/vibe-core/hooks/) are unchanged here.

Assertion changes

Every cited line keeps its expected value or regex. Each existing call now passes the receipt directory and receipt-bound proofs, because absence proofs require a private raw runner receipt (AC-1, AC-5).

  • plugins/vibe/skills/handoff/scripts/test-integrity.test.mjs:150: same []; the proofs are now receipt-bound and the call passes the receipt directory. plugins/vibe/skills/handoff/scripts/test-integrity.test.mjs:152: the mocked-owner negative clones the receipt-bound proofs; its ownerBoundary regex is unchanged.
  • plugins/vibe/skills/handoff/scripts/test-integrity.test.mjs:207: new assertion. A receipt without recoverable mutation bytes is refused.
  • plugins/vibe/skills/handoff/scripts/test-integrity.test.mjs:227, plugins/vibe/skills/handoff/scripts/test-integrity.test.mjs:231 and plugins/vibe/skills/handoff/scripts/test-integrity.test.mjs:235: same regexes (exact assertion failure, primary assertion location); the setup-crash, wrong-assertion and unrelated-stack proofs are receipt-bound.
  • plugins/vibe/skills/handoff/scripts/test-integrity.test.mjs:239: same []; the UI-text-mentions-TypeError proof is receipt-bound.
  • plugins/vibe/skills/handoff/scripts/test-integrity.test.mjs:482: same []; the two proofs on one changed line are receipt-bound.
  • plugins/vibe/skills/handoff/scripts/test-integrity.test.mjs:503: same primary assertion location regex; the proof is receipt-bound.
  • plugins/vibe/skills/handoff/scripts/pr-evidence-comment.test.mjs:67: the multiline proof input now goes through the receipt helper. The rendered-text assertion on the next lines is unchanged. plugins/vibe/skills/handoff/scripts/pr-evidence-comment.test.mjs:62 and plugins/vibe/skills/handoff/scripts/pr-evidence-comment.test.mjs:74 pass the receipt options; their regexes are unchanged.

Contract changes

Internal handoff script exports. Each adds only an optional trailing parameter, and every in-repo caller is updated.

  • specExecutionLimitation(capability, spec, commit, runProbe) and tierEvidenceProblems(capability, e2eSpecs, runProbe): an injectable selected-runner probe; the default probes the runner over SSH.
  • testIntegrityProblems(..., receiptDir, readCandidates) (the changed corpus reader, defaulting to the proof reader), testIntegrityProofShapeProblems(evidence, receiptDir, readSource), prEvidenceShapeProblems(evidence, receiptDir, readSource) and targetedEvidenceProblems(runsFile, commit, worktree, receiptDir): the private receipt directory and a tested-source reader. An absence proof without a receipt is now refused.
  • renderEvidenceComment(evidence, worktree, options): options carry the receipt directory and candidate corpus to the shared validation.
  • capabilityProbeProblems: probeCapability now defaults to the live probe.
  • New: proofCorpusBase(worktree, comparisonOperand) in pr-facts.mjs, the one proof-candidate base for the inventory and the comment.

Tests

  • New cases: test-integrity.test.mjs (Storybook, Vitest, Playwright and receipt cases, and the comment-only, JSX-comment and authored-text refusals); e2e-tier.test.mjs (selected-runner probe, stale and fabricated probes, supported native route); pr-evidence-comment.test.mjs (inventory and comment parity, the shared corpus base, tampered receipts).
  • Changed inputs in existing tests, with expectations unchanged: the shared receipt helpers in test-integrity.test.mjs and pr-evidence-comment.test.mjs now store runner-shaped raw output and an executable mutation, because AC-1 and AC-5 make authored text and comment-only edits insufficient. Every pre-existing [] expectation, regex and rendered-text assertion is byte-identical. Three pr-facts.test.mjs fixtures now commit their synthetic source and carry source identity and selection inputs; their assertions are unchanged.
  • Version contract tests and tools/vibe-bench/src/reply-checker.test.ts are bumped to 1.1.75.

Known limits (non-blocking review notes)

Known limits tracked in ISS-13941.

  • Instrumenter-path ambiguity the gate cannot read from the source is not refused (orchestrator ruling; refusing it rejected ISS-13699's genuine proofs at :516, :611 and :612). Shapes: a later same-matcher assertion whose query has no literal, constant or selector, for example const label = labelFor(grid); await expect(within(canvasElement).queryByText(label)).toBeNull();; and a later assertion that depends on the mutation only semantically, for example a mutation that removes await userEvent.click(dismiss) before a later expect(canvas.queryByRole("dialog")).toBeNull().
  • When a Storybook run reports through the Proxy instrumenter, its direct frame is the instrumenter's source map; ISS-13699's real runs mapped every failure to one unrelated line. In that case only the receipt-bound adjacent mutation, matcher ordering and exact story header bind the assertion, not the frame line.
  • Final adversarial round, non-blocking (orchestrator ruling: only a false green blocks): the restored-output wording check also matches a summary such as "Test Files 1 failed | 2 passed". It is advisory only; every absence receipt must also record restoredExit as the number 0, and missing, string, null and non-zero values are refused (pinned by "ISS-13846 final review A-2 a restored run is proof only with a numeric zero exit").
  • Instrumenter-path ambiguity scanning stops at a function declared on its own line inside the play body, so a later same-matcher assertion after that line is not seen. For example, a play with await expect(within(grid).queryByText("Loading")).toBeNull();, then function noop() {} on the next line, then await expect(within(canvasElement).queryByText("Loading")).toBeNull();.
  • The PC-2 busy-state exemption reads string literals as busy signals, so a card count whose accessible name contains "Loading" is exempt from card-layout proof, for example expect(page.getByRole("article", { name: "Loading dock" })).toHaveCount(1).
  • The tier-C Desktop carry probes the runnerHost and runnerWorktree the evidence names, live at the exact head; nothing binds that host to a configured runner, because the plugin has no runner registry to bind against. A host that reaches the probe and lacks a displayless Electron route passes.
  • The line fallback still demands an absence proof for a query assertion when a nearby line, including a parsed positive assertion beside it, mentions toBeNull, for example expect(screen.queryByText("a")).not.toBeNull(); expect(screen.queryByText("b")).toBeTruthy();. This over-demands proof and cannot pass wrong evidence; main demands a proof for the same line.
  • A line that mixes a positive assertion with an unparsed absence check keeps main's whole-line fallback candidate, so its proof is bound to the line rather than to the check, for example expect(screen.queryByText("a")).not.toBeNull(); expect(screen.queryByText("b")).toBeFalsy();. A parsed absence assertion and an unparsed check on one line yield only the parsed candidate, as on main.
  • On the instrumenter path, a candidate that queries by regular expression is not compared with a later same-matcher query written as a string the mutation's inserted text does not contain, for example the candidate await expect(canvas.queryByText(/loading/i)).toBeNull(); and later await expect(within(canvasElement).queryByText("Loading")).toBeNull();.
  • On the instrumenter path, a later call to a helper imported from another file is not resolved, so a same-matcher check inside it is not seen as ambiguous, for example the mutation, then await expectNoLoading(canvas); imported from ./helpers.
  • On the instrumenter path, the scan for later same-matcher assertions reads an argument only up to its first semicolon, so a later assertion whose argument holds a statement block is not seen as ambiguous, for example await expect(await waitFor(() => { const el = canvas.queryByText("Loading"); return el; })).toBeNull(); after the mutation.
  • As on main, two Playwright absence shapes get no candidate: a count with options, for example await expect(rows).toHaveCount(0, { timeout: 5000 }), and an option-negated matcher, for example await expect(dialog).toBeVisible({ visible: false }).
  • The candidate scan reads assertion text inside test fixture strings as candidates, so this PR's own diff yields 65 fixture-string candidates on this branch (every one also found on main) against 74 on main (the 9 extra are busy-state counts PC-2 now excludes). Those are not real assertions; main behaves the same, so this PR's gate refusal on them is the known gate bug ISS-13900 covers.
  • test-integrity.test.mjs is at 1,361 lines and pr-facts.test.mjs at 999. The repo has no size gate; splitting the new review-fix cases into a sibling test file is a follow-up (guardrails advisory).
  • A line-fallback absence candidate (a check the parser cannot read, such as expect.poll) binds any frame on its line within the line's text, so a second assertion's failure on the same line counts, for example await expect.poll(() => panel.count()).toBeNull(); expect(title).toEqual("Docs");. Main refused this with an exact column match.
  • A frame inside a multiline matcher's own options argument still binds the candidate, for example a failure from other() framed at line 2 of await expect(rows).not.toBeVisible({ / timeout: await other(), / });. Main refused this with an exact line match.
  • The busy-state exemption recognizes only the listed negations, so other ways of excluding busy items still read as a busy count and lose the layout candidate main gives them, for example expect(cards.filter((card) => !card.hasAttribute("aria-busy"))).toHaveLength(1);, === null, === undefined or a ternary.
  • The busy-state check reads only the first count on a line, so a busy count followed by a card count on the same line exempts both, for example expect(screen.getAllByRole("status", { busy: true })).toHaveLength(1); expect(cards).toHaveLength(1);. Main flags that line. Formatted code puts the two statements on separate lines.
  • A mutation in the candidate's own non-story test file is checked only for leaving the candidate's text unchanged, so an edit that widens the asserted container still binds, for example const nav = screen.getByRole("navigation") changed to const nav = document.body before expect(within(nav).queryByText("1-10 of 50")).toBeNull(). Main had no receipt binding and accepted authored text at the coordinate.
  • The busy-state exemption reads any string literal containing the word busy or loading as busy state, so a card or metric count whose query label holds that word gets no layout candidate, for example expect(screen.getAllByText("Busy hours")).toHaveLength(1). Main raised the layout candidate.
  • A changed test file whose path git quotes (non-ASCII bytes under the default core.quotePath) is not recognized as a test file, so it yields no proof candidate, for example a changed naïve.test.ts. Main has the same diff parsing.
  • A Storybook proof whose receipt edits a different production file is still held to the story-file adjacency rule (the edit within 4 lines of the candidate's line, same newline count), so a genuine proof that makes the element present by editing the component elsewhere is refused, for example a candidate on line 398 of a story file whose receipt edit is on line 40 of the component file it renders. This refuses and never passes wrong evidence; main had no receipt check.
  • To keep the instruction-word count from growing, guardrails.md no longer spells out "observe that exact assertion fail, then restore and pass; a green normal run or an unrun mutation is not proof". The worker files keep the fail-then-restore rule (agents/vibe-change-worker.md, agents/vibe-verify-worker.md), and the gate enforces the rest through the raw receipt's mutation and restored exits.
  • AC-4 is delivered as a documented proof route in the handoff skill, plus the validator that checks the resulting receipts. No script runs the Playwright project itself.
  • diagnosticPathMatches in test-integrity.mjs knows the package roots packages/app/, apps/storybook/ and apps/desktop/. Making it repository-neutral is a behavior change, left out of this PR.
  • Three Storybook direct-coordinate negatives in test-integrity.test.mjs are refused through the receipt-binding check, not the identity check. The bound Vitest :1:5 negative and the new F2 test cover identity.
  • pr-evidence-schema.mjs takes the worktree only from this branch's script; installed 1.1.71 rejects that option until this release lands.
  • The version moves from main's 1.1.71 to 1.1.75 (orchestrator version map: 1.1.72 to 1.1.74 are claimed by ISS-13944, ISS-13899: Launch vibe roles that need no session #420 and ISS-13947: Check PR body claims and comment leftovers #419); vibe-core is unchanged here and stays 1.0.0.

Validation

Head 788a8fb (tree 6727ca61): this branch merged with main 182af94 (#418, vibe-core) in 41274c5, then moved to vibe 1.1.75 (version strings only).

  • The second laptop that runs this repository's test lanes went down during the final round, and no tests run on the operator's own laptop (operator ruling: "CI is the full run today"). So the hooks, handoff, Node, Python and bench lanes for this head are carried by this pull request's CI (node-tests, test, vibe-eval-t0, lint, typecheck and the version checks), not by a local run.
  • Light checks on 788a8fb: git diff --check clean; the 1.1.75 changelog check exits 0 (net -2 instruction words against 1.1.71). The real ISS-13699 exact-head (5afa61fa) pull-request inventory, run with this branch's scripts against its unchanged private body copy, has prEvidence true with zero evidence problems, and its evidence comment exits 0. prBody there is false only because the frozen body copy, written when the proof was taken, does not name four symphony-alpha PRs opened since (#9732, #9735, #9739, #9743).
  • Earlier trees: 41274c5 (the merge) passed its hooks suite on the second laptop before it went down. 1abe8fd (tree 1a739ea2, the last pre-merge head): hooks 94/94, handoff 519/519 (test-integrity 40/40). 7992573 (tree 5cd559b3): every Node test file under plugins/, 178 files, 1,883 tests, 0 failed. 6d105057: ruff, pyright, uv run pytest plugins/vibe (668), vibe-bench typecheck, npm test and plugin validation, codex mirror.
  • Each review fix has a test that failed on the source before it.

Docs

Instruction words: about 2 shorter than in 1.1.67 (the instruction-words.mjs changelog check exits 0). Prose the new gates now enforce was trimmed from guardrails.md, vibe-change-worker.md, vibe-verify-worker.md and skills/handoff/SKILL.md.

Real Storybook, Vitest and Playwright failures bind the exact candidate through a private raw receipt; comment-only mutations and authored failure text are refused.
A remote-tier Desktop spec is carried as not run only when a live probe of the selected runner agrees; inventory and PR comment share one decision and one candidate base.
The verifier documents the remote-tier record; the handoff skill documents a credential-free web E2E proof route.
vibe 1.1.68.
…on-proofs

# Conflicts:
#	CHANGELOG.md
#	plugins/vibe/.claude-plugin/plugin.json
#	plugins/vibe/.codex-plugin/plugin.json
#	plugins/vibe/README.md
#	plugins/vibe/tools/python/test_pr_takeover_skill_contract.py
#	plugins/vibe/tools/python/test_vibe_ws8_load_contract.py
#	tools/vibe-bench/src/reply-checker.test.ts
A tier-C probed commit must be a full SHA before it reaches a regex or the remote shell.
A direct Storybook frame on another assertion's line no longer proves this candidate.
Card-layout candidates keep the context scan; only busy or loading counts are excluded.
The verifier documents the raw receipt fields; a misplaced JSDoc is moved back.
…on-proofs

# Conflicts:
#	CHANGELOG.md
#	plugins/vibe/.claude-plugin/plugin.json
#	plugins/vibe/.codex-plugin/plugin.json
#	plugins/vibe/README.md
#	plugins/vibe/tools/python/test_pr_takeover_skill_contract.py
#	plugins/vibe/tools/python/test_vibe_ws8_load_contract.py
#	tools/vibe-bench/src/reply-checker.test.ts
A runner-owned Storybook frame inside another assertion's span no longer proves this candidate; Proxy instrumenter frames keep the receipt-bound mutation check.
The runner header must name exactly the candidate's story, not a sibling whose name it prefixes.
Card-layout candidates exclude only whole-word busy or loading counts.
Tests pin the generic slot report checks for the remote Desktop carry.
On the instrumenter path, a later same-matcher assertion over the same query makes the proof ambiguous and is refused.
A busy: false or aria-busy false filter counts ordinary cards and keeps the layout check.
A test pins that a restored run is proof only with a numeric zero exit.
The v1.1.68 entry now names the sibling-story and same-query instrumenter refusals.
An instrumented Storybook proof is refused when a later same-matcher assertion could also see the mutation: same variable, a query in any quote or regex form equal to the candidate's or in the inserted text, or a same-matcher helper call.
Card counts filtered by a negated busy or loading predicate keep their layout check.
An instrumented Storybook proof must mutate its own story file, and nothing that runs after the mutation (helpers, held queries, composed plays, soft assertions, a helper's calling stories) may share its query.
Card counts excluding busy or loading items in any form keep their layout check.
The remote-tier receipt test now fails on the receipt it names.
Candidate and later queries are keyed from any testing-library call, a selector or a variable holding one; a later query the gate cannot read is a known limit, not a refusal.
Arrow helpers are attributed, and a helper's own definition is no longer counted as a call.
A variable named loading or busy no longer exempts a card count from its layout check.
@shafty023
shafty023 merged commit e9d0892 into main Nov 10, 2026
12 checks passed
@shafty023
shafty023 deleted the vibe/iss-13846-mutation-proofs branch November 10, 2026 18:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant