Skip to content

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
mainfrom
fix/1187-root-tag-outranks-tracker
Draft

spec(1187): two fixes built and rejected — why the tag cannot be trusted (and the leg that argument stood on, corrected)#1212
artokun wants to merge 7 commits into
mainfrom
fix/1187-root-tag-outranks-tracker

Conversation

@artokun

@artokun artokun commented Aug 14, 2026

Copy link
Copy Markdown
Owner

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):

const rootShapeMismatch =
  contentDiffers &&
  !(structureMatches && graphRootWorkflowUuidMatches({ rootGraph, activeWorkflowUuid }));

The positive tag is consulted only as a conjunct of structureMatches. A hand edit differs structurally by definition, so structureMatches is false, 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 where activeState reports the old node count, _nodes reports the new one, and isModified has 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 the structureMatches conjunct.

graph-binding.js:1117 says 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. graphCommandBindingBar sets includeBaselineReadGuard: false for every classified read (graph-binding.js:2091), which disables both midPopulation and baselineReadDesync, so reads like graph_serialize had 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 reset graph.extra in configure(), so a reused app.graph carries 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:

configure(payload with extra:{})      -> tag gone, nodes 10 -> 1
configure(payload with NO extra key)  -> tag gone, nodes  1 -> 2
clear()                               -> tag gone, nodes    -> 0

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:

  1. The measurement is one frontend and says so: "Verified on one frontend, which is exactly why neither statement should be universal." It licenses no universal trust in the tag in either direction.
  2. Binding guard throws a false "live graph is out of sync" on every NEW blank workflow (stale root extra.comfyui_mcp.workflow_uuid) #565 and panel_graph_outline rejects active workflow after tab switch with root-workflow-uuid-mismatch #817 are real reports from builds where the tag did survive, and panel_graph_outline rejects active workflow after tab switch with root-workflow-uuid-mismatch #817 is still open. The asymmetry matters: the existing stale-tag clauses are safe to leave in place when unreachable because they admit — an unreachable widening costs nothing. A fix that relies on the tag being fresh does the opposite: it fails open on exactly the builds that reported the problem.
  3. Build-independent: graph-binding.js:1145-1161 records as a known, deliberate gap that sealProvenRootBinding will 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: call captureCanvasIntoTracker (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, and isModified === true makes graphRootMismatchesActiveWorkflow return false immediately (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:

  • it fires on merely reading a wrongly-mounted canvas, not only after a real edit;
  • a capture that sees a change "pushes an undo entry and clears the redo queue" (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 is false. 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:

  1. The tag can be stale → identity is not proof.
  2. The tracker lags → content is not currently accurate.
  3. Making the tracker current flips 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 rootContentProvesActiveWorkflow and contentProofExclusiveAmongOpen already 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:unit green (4399 tests, 0 fail).

🤖 Generated with Claude Code

…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.
Copilot AI balanced review requested due to automatic review settings August 14, 2026 05:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

artokun and others added 4 commits August 13, 2026 22:06
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>
@artokun artokun changed the title fix(1187): a proven root tag outranks a lagging ChangeTracker snapshot spec(1187): two fixes built and rejected — why the tag cannot be trusted and settling cannot verify Aug 14, 2026
artokun and others added 2 commits August 14, 2026 16:30
… 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>
@artokun artokun changed the title spec(1187): two fixes built and rejected — why the tag cannot be trusted and settling cannot verify spec(1187): two fixes built and rejected — why the tag cannot be trusted (and the leg that argument stood on, corrected) Aug 14, 2026
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.

2 participants