spec(1187): two fixes built and rejected — why the tag cannot be trusted (and the leg that argument stood on, corrected) - #1212
Draft
artokun wants to merge 7 commits into
Draft
Conversation
…despite a matching UUID Claiming #1187. Diagnosis: web/js/lib/graph-binding.js:2112-2114 consults the positive root-tag match only as a CONJUNCT of structureMatches, so a matching workflow UUID is structurally incapable of rescuing a structural difference. Lib-only, so it does not contend with the monolith PRs in flight.
There was a problem hiding this comment.
Copilot wasn't able to review any files in this pull request.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The defect was one boolean. `rootShapeMismatch` read
contentDiffers && !(structureMatches && tagMatches)
so the positive tag was consulted only as a CONJUNCT of `structureMatches`. On a
structural difference — which is precisely what "the user added a node" means —
`structureMatches` is false, the whole term is false, and a matching identity stamp
was structurally INCAPABLE of rescuing the read. The tag could only ever confirm a
verdict the structure had already reached.
THE EVIDENCE IS NOT SYMMETRIC. extra.comfyui_mcp.workflow_uuid is IDENTITY, stamped
by sealProvenRootBinding on a root proven to be this workflow's. changeTracker
.activeState is a lagging SNAPSHOT: ComfyUI captures on user-input events, so after a
hand edit there is a window where activeState still reports the old node count while
_nodes reports the new one — and wf.isModified has not flipped either, so
graphRootMismatchesActiveWorkflow's dirty-tab escape hatch does not fire. In that
window every read AND every mutation refused, telling the user their own canvas "is
bound to a different graph". It cleared itself once the tracker captured, which is
why it reads as intermittent rather than as the deterministic race it is.
THIS WIDENS #349's STRUCTURAL LINE, and says so rather than presenting itself as a
pure bug fix. A tagged root that differs STRUCTURALLY is now permitted where before
only content drift was. The residual exposure is the already-accepted
sealProvenRootBinding stranded-duplicate gap, extended from content drift to
structural drift — a real widening of a known hole, not a new one.
Still refused, each ordered ahead of this term and untouched: a FOREIGN tag
(rootUuidMismatch), an UNTAGGED root, #618's count-short root inside the reconnect
window (midPopulation), and a dirty tab mutating without a positive match.
THE OLD #696 TEST WAS REPLACED, NOT DELETED. It asserted the very boundary this
fixes. The seven structural signatures now run as one table across all THREE tag
states — permitted when the tag matches, refused when absent, refused when foreign —
so deleting the fix reddens the first section and deleting the guard reddens the
other two. Both directions are pinned.
AND THE TABLE WAS MISSING THE REPORTED CASE. Every signature in it made the live
canvas run level with or BEHIND the tracker; the issue is a node ADDED, running
AHEAD (98 vs 99). That direction matters on its own because #618's midPopulation
absorbs the count-short one inside the reconnect window, so only count-long reaches
the shape term. Added.
Each case also asserts graphRootMismatchesActiveWorkflow is still true, because a
fixture with isModified:true would make it bail, leave contentDiffers false, and pass
for a reason that has nothing to do with this fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the approach in ce22c1b, which the codex review rejected P0 — correctly. WHY THE FIRST ATTEMPT WAS UNSAFE. It made a matching root tag outrank the content comparison: `contentDiffers && !tagMatches`. That does fix the reported symptom, but lib/graph-binding.js already records at #565 and #817 that some ComfyUI builds do NOT reset graph.extra in configure(), so a reused app.graph carries the PREVIOUS workflow's tag. Tag A can sit on canvas B. Trusting the tag alone therefore permits reads AND writes against the wrong canvas — the exact #349 hazard the fence exists for. The tell was in the file the whole time: every stale-tag mitigation it ships (staleTagOnEmptyCanvas, contentProvesActiveWorkflow) demands CONTENT proof, and none trusts the tag by itself. The attempt inverted the file's own rule. The review also showed the claimed containment was caller-dependent: read dispatch sets includeBaselineReadGuard:false, which disables midPopulation AND baselineReadDesync, so paths like graph_serialize had no backstop at all. WHAT THIS DOES INSTEAD. The predicate was never wrong — its INPUT was stale. ComfyUI captures changeTracker.activeState on user-input events, so right after a hand edit activeState reports the old node count while _nodes reports the new one, and isModified has not flipped, so the dirty-tab escape hatch does not fire either. The panel now settles that: on root-shape-mismatch, on a root already carrying THIS workflow's tag, on a tab that still reads clean, it asks ComfyUI to capture and then re-runs the SAME resolver. Once the snapshot is current, isModified flips and the existing dirty-tab path answers. No guard is widened. graph-binding.js is unchanged except for a comment recording why it must not be. A wrong canvas is not rescued by this, which is the whole point: after the capture the CONTENT still has to agree, and a canvas that really is another workflow's cannot make it agree. EVERY GUARD IS ITSELF AN OPERATION THAT CAN FAIL. The capture can be unavailable on an older frontend, swallowed by a no-op window, resolve asynchronously, or throw. Each of those returns the ORIGINAL refusal — today's behaviour exactly. Only a capture that PROVABLY landed reaches a different answer. A helper meant to stop a false refusal must never become a way to skip a true one. THE SIDE EFFECT, STATED RATHER THAN BURIED: a capture that sees a change pushes an undo entry and clears the redo queue. That is a capture ComfyUI itself would take on the next user-input event, for an edit the user really made — accelerated, not invented. The trigger is deliberately narrow so it cannot fire routinely. TESTS. The old #696 structural test is RESTORED, not replaced — it asserts the boundary that must not move, and ce22c1b had rewritten it to bless the widening. It keeps the added-node signature, which the table was missing: every case in it made the live canvas run level with or BEHIND the tracker, while the reported bug runs AHEAD (98 vs 99). Two new tests model the settle end to end and pin that a stale tag on a genuinely different canvas is still refused afterwards. The #545 source-wiring assertion now pins delegation at both links. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two mutations survived a green suite: dropping the tag precondition, and discarding the re-resolve result. Both are invisible to source-text assertions — the shared fence harness does not inject graphRootWorkflowUuidMatches or captureCanvasIntoTracker, so the settle path hit a ReferenceError, was caught by its own fail-closed catch, and silently degraded to the original refusal. The suite was green because the feature was switched off, which is the worst way for a test to pass. The wrapper is now extracted and CALLED with its three dependencies injected, covering: the fresh verdict is returned rather than the stale one; an untagged root is never captured (a capture pushes an undo entry and clears redo, so this is not free); only root-shape-mismatch is settled; an already-dirty tab is skipped; every capture outcome short of a proven landing keeps the refusal, including "pending"; and a throw from any dependency — tag check, capture, or the re-resolve itself — fails closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… worse Reverts ce22c1b, c3064c8 and 704123d back to main's behaviour. The branch keeps the investigation; it ships no code. ATTEMPT 1 — let a matching root tag outrank the content comparison. Rejected P0: #565 and #817 record that some ComfyUI builds do not reset graph.extra in configure(), so a reused app.graph carries the PREVIOUS workflow's tag. Tag A can sit on canvas B, and trusting the tag alone permits reads AND writes against the wrong canvas. ATTEMPT 2 — capture the lagging ChangeTracker, then re-ask. This rested on a claim I did not check: 'after the capture the CONTENT still has to agree'. It does not. The capture flips isModified, and isModified===true makes graphRootMismatchesActiveWorkflow return false immediately — the dirty-tab escape hatch. The comparison is not re-run, it is SUPPRESSED. So the capture does not validate the canvas; it silences the check that would have caught it. Same P0, reached by a longer route, plus two costs attempt 1 did not have: it fires on merely READING a wrongly-mounted canvas, and the capture records canvas B as workflow A's change and can clear A's redo history. The test I wrote to prove attempt 2 safe was vacuous. It forced rootUuidMismatch:true, which is the case where the tag is provably foreign — not the dangerous one, where a STALE tag still MATCHES and that flag is false. It asserted the safe case and named it the dangerous one. WHY THIS IS HARD, for whoever picks it up. The tag can be stale, so identity is not proof. The tracker lags, so content is not currently accurate. Making the tracker current flips isModified, which disables the content check outright. No two of those three can be combined into a proof using the tracker alone, because the tracker follows whatever canvas is MOUNTED — it cannot distinguish 'the user edited A' from 'this is B wearing A's tag'. A real fix needs evidence the tracker does not carry: the workflow's own persisted/serialized content, which is what rootContentProvesActiveWorkflow and contentProofExclusiveAmongOpen already reason about, and it likely depends on #1014 (identity lost across a reconnect) landing first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… stood on Refs #1187. No behaviour change: both attempted fixes stay reverted, and the only code touched is a comment. The record lived solely in a PR body with an empty diff, which is the one place the next person to edit `rootShapeMismatch` will not look. It now lives in docs/design/graph-binding-tag-vs-tracker.md, pointed at from the expression itself. CORRECTION — the P0 was argued from a premise the file already qualified. The rejection of attempt 1 cited graph-binding.js:1757-1777 (#565/#817: some builds do not reset graph.extra in configure(), so a reused app.graph carries the previous workflow's tag) and stopped fifteen lines short. Lines 1779-1794 of that same comment record a measurement from 2026-08-09 on ComfyUI frontend 1.48.7 where configure() and clear() BOTH drop the tag, and conclude the premise "is therefore frontend-specific, and the clauses may simply not be reachable on current builds". #1187's reporter is on 1.48.6, the same generation. A reader who followed the old citation and read on would have found what looks like a refutation of the rejection — and rebuilt attempt 1, which is the exact outcome this record exists to prevent. The rejection still stands, on three legs that do not depend on configure(): 1. the measurement is one frontend and says so ("Verified on one frontend, which is exactly why neither statement should be universal"); 2. #565/#817 are real reports from builds where the tag DID survive, and #817 is still open. The existing stale-tag clauses are safe when unreachable because they ADMIT; a fix that RELIES on the tag being fresh fails open on those same builds; 3. build-independent, and the one to reason from: graph-binding.js:1145-1161 already records that sealProvenRootBinding can stamp A's tag onto a CLOSED duplicate's stranded canvas, because the exclusivity sweep sees only open tabs. The structure conjunct is what bounds that gap to "still structurally A", and attempt 1 deletes exactly that bound. Also corrected: the doc previously attributed "structural equality alone cannot tell the active tab's canvas from a DUPLICATE tab's" to the case for STRUCTURE. It is the file's case for IDENTITY (graph-binding.js:1119-1126); the case for structure is at 1128-1133, "so #349 is untouched". Re-verified against main as merged here: every named file, function and line reference still exists, both attempts are byte-for-byte reverted against the merge-base, and `npm run test:unit` is green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #1187. Ships no behaviour change — the two attempted fixes are reverted and the runtime is back to
main's behaviour. What it now ships is the record:docs/design/graph-binding-tag-vs-tracker.md, plus a pointer comment at the expression a third attempt would edit.Two fixes were built, reviewed and rejected. This records why, so the third attempt does not repeat them.
The bug is real
web/js/lib/graph-binding.js,resolveGraphBindingVerdict(currently 2183-2185):The positive tag is consulted only as a conjunct of
structureMatches. A hand edit differs structurally by definition, sostructureMatchesisfalse, the term collapses, and the workflow's own identity stamp can never rescue the read. Meanwhile ComfyUI's ChangeTracker captures on user-input events, so there is a window whereactiveStatereports the old node count,_nodesreports the new one, andisModifiedhas not flipped — so the dirty-tab escape hatch does not fire either. Every read and every mutation refuses. It self-clears once the tracker captures, which is why it presents as intermittent.Attempt 1 — let the tag outrank the content comparison. Rejected, P0.
contentDiffers && !tagMatches— i.e. delete thestructureMatchesconjunct.graph-binding.js:1117says of that identity+structure pair that "the relaxation is deliberately NARROW, and both conjuncts are load-bearing", and the STRUCTURE clause (1128-1133) states what its half buys: a different node set, ids, types, links, groups, reroutes, subgraphs, definitions or content-bearing extra "all remain refusals, so #349 is untouched". Deleting it removes the half that keeps #349 closed.Every stale-tag mitigation the file already ships —
staleTagOnEmptyCanvas,contentProvesActiveWorkflow— demands content proof. None trusts the tag by itself. The attempt inverted the file's own rule.Also surfaced: the containment claimed for it was caller-dependent.
graphCommandBindingBarsetsincludeBaselineReadGuard: falsefor every classified read (graph-binding.js:2091), which disables bothmidPopulationandbaselineReadDesync, so reads likegraph_serializehad no backstop at all.Correction on re-verification (2026-08-14) — read before re-deriving the P0
The original rejection cited
graph-binding.js:1757-1777(#565/#817: some builds do not resetgraph.extrainconfigure(), so a reusedapp.graphcarries the previous workflow's tag). That citation stops fifteen lines too early. Lines 1779-1794 of the same comment record a measurement taken 2026-08-09 on ComfyUI frontend 1.48.7:On that build the tag does not survive a content change, and the file concludes the #565/#817 premise "is therefore frontend-specific, and the clauses may simply not be reachable on current builds". #1187's reporter is on frontend 1.48.6, the same generation.
So the
configure()mechanism alone does not carry the P0 on current frontends, and a reader who followed the old citation would have found what looks like a refutation. The rejection still stands, on three legs that do not depend on it:graph-binding.js:1145-1161records as a known, deliberate gap thatsealProvenRootBindingwill stamp A's tag onto an untagged root serializing equal to A with no other open workflow matching it — which a closed duplicate's stranded canvas satisfies, since the exclusivity sweep can only see open tabs. The structure conjunct is what bounds that gap to "still structurally A". Attempt 1 deletes exactly that bound.Leg 3 involves no frontend behaviour at all and is the one to reason from.
Attempt 2 — settle the lagging tracker, then re-ask. Rejected, same P0, and worse.
On
root-shape-mismatch+ a matching tag + a clean tab: callcaptureCanvasIntoTracker(web/js/comfyui-mcp-panel.js:2343), then re-run the same resolver, unchanged.It rested on one sentence never verified — "after the capture the content still has to agree." It does not. The capture flips
isModified, andisModified === truemakesgraphRootMismatchesActiveWorkflowreturnfalseimmediately (graph-binding.js:1017). The comparison is not re-run, it is suppressed. The capture does not validate the canvas; it silences the check that would have caught it. Same P0 by a longer route.Two costs attempt 1 did not have:
comfyui-mcp-panel.js:2397-2398), recording canvas B as workflow A's change. A user could lose a redo by reading.And the test written to prove it safe was vacuous. It forced
rootUuidMismatch: true— the case where the tag is provably foreign. The dangerous case is a stale tag that still matches, where that flag isfalse. It asserted the safe case and labelled it the dangerous one.Why this is hard
Three facts that cannot be combined into a proof using the tracker alone:
isModified→ which disables the content check outright.The root reason: the ChangeTracker follows whatever canvas is mounted. It cannot distinguish "the user edited A" from "this is B wearing A's tag", because in both cases it faithfully reports the canvas in front of it.
What a third attempt probably needs
Evidence the tracker does not carry — the workflow's own persisted/serialized content, which is what
rootContentProvesActiveWorkflowandcontentProofExclusiveAmongOpenalready reason about. Compare the live root against workflow A's own stored state, not against a snapshot of whatever is mounted.That likely depends on #1014 (a saved workflow loses its identity across a reconnect — still open) landing first, since it is the case where no published UUID exists to anchor any of this.
Verification performed
Both attempts were carried to completion before being rejected — full unit suite green, and mutation-verified (6 mutations on attempt 2, all confirmed red) — which is precisely why they are worth recording. A green suite and a clean mutation run did not make either one safe. Attempt 2's suite passed 143/143 while the P0 path was untested, and the shared fence harness does not inject the settle path's dependencies, so those tests silently exercised a degraded no-op.
Re-verified on this branch after merging
main(2026-08-14): every file, function and line reference above re-checked against the merged tree;npm run test:unitgreen (4399 tests, 0 fail).🤖 Generated with Claude Code