fix(1126): a combo write is decided by whether the panel could READ the option list - #1133
Conversation
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.
Not mergeable as it stands — holdingTwo independent reasons, both established rather than suspected. 1. The escape cannot be reached by any current callerMeasured against the live orchestrator (comfyui-mcp 0.51.16):
2. The implementation has confirmed defects, including one that falsifies its own safety claim
Also recorded: two rejected alternativesInferring the case from Accepting any off-list string by default (no opt-in) — broke three further independent guards: #347's clear-to- What would make this shippable
Leaving this as a draft rather than merging an inert escape with confirmed defects. #1126 stays open with the workaround that does work today. |
Review finished — one root cause behind nearly all of itThe full verify pass landed (29 agents, 11 refuted / 4 plausible / 13 confirmed). Its synthesis is sharper than my hold note above, and worth recording precisely: the escape is verified working only for a direct, non-promoted combo with a real closed list, and broken in most other shapes — almost all traceable to one decision, reusing That decision was mine, and I made it as a safety fix two commits in. It caused two confirmed defects at once: 1. It routes the accepted string into the #667 rail label-adoption, so a NUMBER lands on the widget. Verified on the committed snapshot: rail 2. It makes the flag unusable inside a subgraph, with a diagnosis that states a falsehood. The #1126 node is very plausibly promoted, and then the parent rail mirrors the placeholder list. Verified: the write is refused with Two further confirmed gaps in the ladder: 3. Gated on 4. A non-string with the flag set re-throws the raw Plus the lower-severity set already noted: the panel's activity summary renders an admittedly-unvalidated write as an ordinary success; a non-boolean ConclusionHolding stands, and the fix list is now concrete rather than a guess:
Worth being explicit that the review earned its keep here twice over: my "safety" commit introduced the two worst defects, and the test suite had gone green over all of it. |
…n empty list TWO HALVES, because the report contains two distinct failures. REBUILD. reapplyDefsToLiveNodes stamped ctor.nodeData and reconciled UNKNOWN widget names but never touched options.values — even though refreshComboOptionsFromDefs, which does exactly that, sits thirty lines above it in the same module and was called only from the set_widget path. Rebuilding was delegated entirely to app.refreshComboInNodes(), whose per-node effect the panel never observes and never verifies. So a newly added CheckpointLoaderSimple kept an empty ckpt_name list while refresh_nodes answered refreshed:true, and the agent found out at queue time via "Value not in list (… not in [])". Doing it in-panel also makes the combo state correct independently of whether that frontend call is waited on, which is a partial answer to #1193. DISCLOSE. Every input describeNodeDefRefresh takes is STRUCTURAL — app present, defs obtained, register ran, combo API present, combo resolved. refreshed:true was a claim about API calls RESOLVING, not about the definitions being usable. The payload said ckpt_name: [[], {…}], the panel held it at register and at reapply, and discarded it. refreshed STAYS TRUE (#1133). A server with zero checkpoints is a real answer, and #507/#1133 establish that empty lists are sometimes legitimate; flipping the verdict would re-refuse via the verdict exactly what #1133 permits via the write path. Disclosure, not failure. Read from the defs payload already in hand, NOT by re-reading widgets after app.refreshComboInNodes() resolves (#1193) — a disclosure that depended on that await would report nothing if the call were ever abandoned mid-flight. The note points at the BACKEND: the panel did refresh, the server itself published an empty list, so refreshing again returns the same thing and the remedy is model paths or a restart. Naming the refresh as its own remedy is the wrong-remedy failure this repo keeps removing. SCOPED to types present on the graph. The backend may publish dozens of empty combos for packs the user is not using; reporting those buries the one node that matters. PERF (#1193 measured register/reapply at 3.97s of a 9s run): the merged {...required, ...optional} map is hoisted per TYPE for the whole sweep instead of being rebuilt per node, so a graph of N same-type nodes spreads once. #981's HOLE, EXTENDED RATHER THAN RE-DUG: the refreshed:true branch returns a fixed object literal, so a field the verdict carries but the whitelist does not name is dropped on exactly the successful path where it matters. #981 fell into this at this same line. The guard test now names every field that must survive it. Two of my own errors, both caught by the repo's own guards rather than by inspection: a NUL byte written into asset-staleness.js (caught by the stray-control-character test), and the #981 source guard pinning the literal I extended. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex review, P1 — and it is #756's rule broken in the note that cites #756's rule. The first version said "the backend is not finding it: check that server's model paths, then restart ComfyUI". That is an INFERENCE. An empty list is equally a combo this node populates CLIENT-side, a pack that publishes an empty enum deliberately, or a server with genuinely zero of that asset. Naming one is the same defect as the workflow fence asserting "the workflow was switched" for every mismatch (#750). Worse, it said "setting one of these widgets will be refused until the backend lists it", which is simply FALSE. set-widget.js treats an authoritative empty list as unknowable and PERFORMS the write, reporting empty_option_list: true (#507/#1133). That sentence would have talked an agent out of a write that succeeds — a fabricated constraint, in a disclosure whose whole purpose is to stop the panel over-claiming. The note now reports the observation, states plainly that the cause is NOT established here, and says the true thing about writes so the agent is not left guessing. Also from the review: - the source guard pinned the spread and the note but not the list mapping itself, so dropping `empty_combo_lists` from the forwarded object would have gone unnoticed. Both fields are now pinned. - the perf comment claimed safety it had not measured. Hoisting the merged input map removes the per-node spread; it does not make the sweep free, since rebuilding still walks every widget and copies each authoritative array. Stated, not claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…either Third pass on the same sentence, and the same rule each time. It said "refreshing again returns the same thing". That is a PREDICTION about another command — the exact thing the comment two lines above forbids. A later refresh does a fresh /object_info read and can observe changed server state: models added, a restart, a pack installed. The clause was a guess wearing an observation's clothes. What survives is only what this refresh established: the empty list is what /object_info answered, not a step the panel skipped. The agent can decide for itself whether retrying is worthwhile; the note's job is to say what was seen. Three versions of one sentence, rejected for: inferring a cause, asserting a refusal that #1133 disproves, and now predicting another command's result. Worth recording because the sentence that keeps failing is the one explaining that the panel must not over-claim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth and final pass on one sentence. "not a step the panel skipped" is a claim about the PANEL's internals, and this disclosure is built from the /object_info payload alone — it can say what the server answered, not what the panel did or did not do. Deleted rather than re-argued: the surrounding sentence already carries everything it was there to convey. The full tally on this one sentence, because the pattern is the point: it inferred a cause, then asserted a refusal that #1133 disproves, then predicted what a later refresh would return, then claimed a panel-internal fact. Four rounds, each caught by review rather than by me, in the note whose entire subject is the panel not over-claiming. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…se an empty authoritative list (#1218) * chore(1172): claim — refresh_nodes reports refreshed while combo lists stay empty Claiming #1172. Diagnosis: reapplyDefsToLiveNodes never rebuilds combo option arrays, and describeNodeDefRefresh's inputs are all STRUCTURAL — nothing inspects the content it just registered. Bulk lands in lib/asset-staleness.js. * fix(1172): rebuild combo options in the reapply sweep, and disclose an empty list TWO HALVES, because the report contains two distinct failures. REBUILD. reapplyDefsToLiveNodes stamped ctor.nodeData and reconciled UNKNOWN widget names but never touched options.values — even though refreshComboOptionsFromDefs, which does exactly that, sits thirty lines above it in the same module and was called only from the set_widget path. Rebuilding was delegated entirely to app.refreshComboInNodes(), whose per-node effect the panel never observes and never verifies. So a newly added CheckpointLoaderSimple kept an empty ckpt_name list while refresh_nodes answered refreshed:true, and the agent found out at queue time via "Value not in list (… not in [])". Doing it in-panel also makes the combo state correct independently of whether that frontend call is waited on, which is a partial answer to #1193. DISCLOSE. Every input describeNodeDefRefresh takes is STRUCTURAL — app present, defs obtained, register ran, combo API present, combo resolved. refreshed:true was a claim about API calls RESOLVING, not about the definitions being usable. The payload said ckpt_name: [[], {…}], the panel held it at register and at reapply, and discarded it. refreshed STAYS TRUE (#1133). A server with zero checkpoints is a real answer, and #507/#1133 establish that empty lists are sometimes legitimate; flipping the verdict would re-refuse via the verdict exactly what #1133 permits via the write path. Disclosure, not failure. Read from the defs payload already in hand, NOT by re-reading widgets after app.refreshComboInNodes() resolves (#1193) — a disclosure that depended on that await would report nothing if the call were ever abandoned mid-flight. The note points at the BACKEND: the panel did refresh, the server itself published an empty list, so refreshing again returns the same thing and the remedy is model paths or a restart. Naming the refresh as its own remedy is the wrong-remedy failure this repo keeps removing. SCOPED to types present on the graph. The backend may publish dozens of empty combos for packs the user is not using; reporting those buries the one node that matters. PERF (#1193 measured register/reapply at 3.97s of a 9s run): the merged {...required, ...optional} map is hoisted per TYPE for the whole sweep instead of being rebuilt per node, so a graph of N same-type nodes spreads once. #981's HOLE, EXTENDED RATHER THAN RE-DUG: the refreshed:true branch returns a fixed object literal, so a field the verdict carries but the whitelist does not name is dropped on exactly the successful path where it matters. #981 fell into this at this same line. The guard test now names every field that must survive it. Two of my own errors, both caught by the repo's own guards rather than by inspection: a NUL byte written into asset-staleness.js (caught by the stray-control-character test), and the #981 source guard pinning the literal I extended. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(1172): the note may not name a cause, and must not predict a refusal Codex review, P1 — and it is #756's rule broken in the note that cites #756's rule. The first version said "the backend is not finding it: check that server's model paths, then restart ComfyUI". That is an INFERENCE. An empty list is equally a combo this node populates CLIENT-side, a pack that publishes an empty enum deliberately, or a server with genuinely zero of that asset. Naming one is the same defect as the workflow fence asserting "the workflow was switched" for every mismatch (#750). Worse, it said "setting one of these widgets will be refused until the backend lists it", which is simply FALSE. set-widget.js treats an authoritative empty list as unknowable and PERFORMS the write, reporting empty_option_list: true (#507/#1133). That sentence would have talked an agent out of a write that succeeds — a fabricated constraint, in a disclosure whose whole purpose is to stop the panel over-claiming. The note now reports the observation, states plainly that the cause is NOT established here, and says the true thing about writes so the agent is not left guessing. Also from the review: - the source guard pinned the spread and the note but not the list mapping itself, so dropping `empty_combo_lists` from the forwarded object would have gone unnoticed. Both fields are now pinned. - the perf comment claimed safety it had not measured. Hoisting the merged input map removes the per-node spread; it does not make the sweep free, since rebuilding still walks every widget and copies each authoritative array. Stated, not claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(1172): the note may not predict what a later refresh will return either Third pass on the same sentence, and the same rule each time. It said "refreshing again returns the same thing". That is a PREDICTION about another command — the exact thing the comment two lines above forbids. A later refresh does a fresh /object_info read and can observe changed server state: models added, a restart, a pack installed. The clause was a guess wearing an observation's clothes. What survives is only what this refresh established: the empty list is what /object_info answered, not a step the panel skipped. The agent can decide for itself whether retrying is worthwhile; the note's job is to say what was seen. Three versions of one sentence, rejected for: inferring a cause, asserting a refusal that #1133 disproves, and now predicting another command's result. Worth recording because the sentence that keeps failing is the one explaining that the panel must not over-claim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(1172): drop the last unsupported clause from the note Fourth and final pass on one sentence. "not a step the panel skipped" is a claim about the PANEL's internals, and this disclosure is built from the /object_info payload alone — it can say what the server answered, not what the panel did or did not do. Deleted rather than re-argued: the surrounding sentence already carries everything it was there to convey. The full tally on this one sentence, because the pattern is the point: it inferred a cause, then asserted a refusal that #1133 disproves, then predicted what a later refresh would return, then claimed a panel-internal fact. Four rounds, each caught by review rather than by me, in the note whose entire subject is the panel not over-claiming. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reworked against the hold — the decision no longer comes from the callerThe fix list was concrete, but items 1–5 were all downstream of item 6:
What that does to the fix list:
Two things I want visible rather than buried:
Verification: 18 tests both directions, 5 mutations applied and each killed by a named test (unconditional adoption / cross-check skipped / frame dropped / server gate removed / summary disclosure deleted). Left as a draft: the shape of the decision changed, so this wants a fresh read rather than a diff against the held version. |
c11a027 to
dbfe7ca
Compare
…licit escape panel_set_widget refused an absolute .fbx path on a custom node's dynamic file combo whose live option list was ["empty"] — one placeholder — although the node's runtime handler accepts a path. The reporter's only workaround was copying the file into ComfyUI's input directory, which defeats a node whose purpose is taking a path. WHY IT IS AN ASSERTION AND NOT AN INFERENCE, which is the whole design and was established by getting it wrong first. The obvious fix is to notice that /object_info declares this input's option list EMPTY and treat the live placeholder the way #507 already treats a live empty list. I wrote that, and it broke three #507 invariants which are correct: the SAME condition covers the StarNodes combo whose own frontend JS populates a list of REAL models, where the live list is genuinely richer than the server's empty one and an off-list value is a typo worth refusing. Nothing available here separates "placeholder" from "real options the server could not enumerate". Not the option names — `None` is a real, meaningful option on plenty of nodes, so a placeholder denylist would refuse legitimate writes and admit off-list ones. Not the server declaration, per the above. The caller knows the node; the panel does not. So `allow_unlisted` is a per-call assertion, and it is deliberately narrow: - It sits LAST in the recovery ladder, after the /object_info refresh, the upload-asset confirmation and #507's server-declared-empty acceptance. It can only convert what would otherwise be a flat refusal, never pre-empt an authoritative mechanism. A merely STALE list is still refreshed and validated strictly — a test pins that, because the flag quietly becoming "skip the refresh" is the failure mode to fear. - STRING only. #240's reason for strict membership is that a NUMBER can be reinterpreted as an INDEX into a real list, and a real list DOES exist on the widget here even though it cannot enumerate what is valid. No file path is a number, so this keeps all of #240. - Ordinary membership still decides first, so a caller who passes the flag defensively keeps the guard for values the list does contain — and is not told the write went unvalidated when it did not. - The write is DISCLOSED (`off_list_value_accepted` + a note), taken from the COERCION-TIME verdict rather than re-derived from the rejection: with a stateful options function the retry can be admitted by ordinary membership, and claiming an unvalidated write then would be false. - It has its OWN marker, not `emptyAcceptanceUsed`. That one gates the promoted-write sibling cross-check, whose argument is "the inner list is empty so the server declaration governs" — an argument this path does not make. The off-list refusal now NAMES the flag, with its condition stated. Without that the escape is undiscoverable, which is how the reporter ended up copying files around; and worded as a branch, not a retry hint, so a genuinely closed option set is not handed a way to write nonsense into it. WidgetWriteError's constructor drops meta it does not name, which is why the first version of this appeared to do nothing — `offList` is now destructured there, with a comment saying so. Two source-locator tests pinned graph_set_widget's exact destructuring and broke on the added argument. They now match the param list loosely and assert the bridge-owned stamp separately, so they fail for their own reason rather than on any future signature. 3978 tests pass (3977 + 1 todo), typecheck clean, node --check OK. Refs #1126
…oss-check My own gap, and I had written a comment claiming the opposite. The unlisted acceptance set only its own marker, so it bypassed #507's promoted-write sibling cross-check entirely — and a promoted write assigns the value to the parent's authoritative RAIL widget and every display proxy, whose option lists can be real and closed. An unlisted value would have landed there with nothing validating it: the #507 hazard with a new trigger. The comment justified skipping it as avoiding "a rail refusal on the caller's assertion about the INNER widget". That has it backwards. A refusal is recoverable and the caller can address the rail directly; a corrupted rail in the serialized parent graph is not. So the path now sets `emptyAcceptanceUsed` as well, which is what gates that check, and keeps `offListAcceptanceUsed` as the separate signal driving the reply's disclosure — the empty-list acceptance is a stronger statement and must not be reported as this one. Pinned at coercion time, mirroring #507's own confirmation-round test, because that marker is exactly what the cross-check keys on: a refactor that stops setting it silently disables the rail check and must fail there. The end-to-end rail refusal stays covered by #507's existing cross-check tests, since this routes into the same gate — a second promoted fixture would re-test their machinery rather than anything new. I tried to add one, could not get it to reach the promoted path, and removed it rather than leave a test that asserts nothing. 3979 tests pass (3978 + 1 todo), typecheck clean. Refs #1126
… rejects
Measured against the live orchestrator (comfyui-mcp 0.51.16) rather than assumed:
panel_set_widget params: node_id, widget, value, clear, retry_of
additionalProperties: false
{"allow_unlisted": true} -> MCP error -32602: Unrecognized key: "allow_unlisted"
So the escape this PR adds is not reachable from a current client at all, and the
refusal was telling agents to "retry with allow_unlisted:true" — a recovery that
returns a schema error. That is the #932 circularity again: a refusal advertising a
remedy the caller cannot perform, which costs an agent a retry loop instead of the
one thing that does work today.
The message now states the requirement instead of an instruction: set the option IF
your client exposes it, and if the answer is `Unrecognized key` the client predates it
and cannot reach it however the call is phrased — do not retry variants; copy the file
into ComfyUI's input directory or update comfyui-mcp. The panel half stays, because the
panel must tolerate an older orchestrator either way (the same reason #236's vendored
vocabulary hash exists), and it is inert until the argument is declared upstream.
ALSO RECORDED: a blanket "accept any off-list STRING by default" was implemented to
remove the opt-in entirely, and reverted. It looked defensible — a string cannot be
reinterpreted as a dropdown INDEX, which is #240's actual hazard — but the suite showed
the guard is doing more than that, in ways worth keeping:
- #347: clearing a combo to "" must refuse, so `clear: true` does not become a
combo-strictness bypass.
- "an invalid value is REJECTED, not coerced to another enum" — on a `lllite_name`
FILE combo, which is the common case. A wrong filename otherwise fails deep in
model loading with an error that names neither the widget nor the workflow.
- the same for a dynamic (function) option list.
Three independent guards, not one. The friction is real but it is specific to combos
whose enum genuinely cannot enumerate the valid values, and separating those from the
majority is exactly what no available evidence does — so the narrow, explicit,
disclosed opt-in stands.
3979 tests pass (3978 + 1 todo), typecheck clean.
Refs #1126
Both calls passed ACCEPT_EMPTY with the same arguments, so the second covered nothing. The intent was the no-opt-in case and then acceptEmptyComboOptions alone: the first now passes HOOKS, and both pin the message rather than only the error class. Found by review (CONFIRMED), and reported alongside the branch's other findings. Refs #1126
… assertion
A dynamic combo's option list comes from `options.values(widget)` — the NODE's own
callback. It can mutate the widget and it can fail. When it failed, the panel had
compared the value to nothing, yet answered with a refusal that reached the user as a
verdict about their value: a node whose runtime handler takes an absolute path had the
path refused as though the path were wrong.
Replaces the caller-asserted `allow_unlisted` escape with the panel's own observation.
* The list could NOT be read (callback threw / returned a non-list / absent) — the
valid set is not knowable here, exactly like #507's empty list, and #240's reason for
strict membership has no list to index into. Accepted as a LAST resort, after the
authoritative refresh and the upload probe, and only when /object_info declares this
input's list empty too. Non-empty STRING only: #240 keeps a number out of a list that
exists but cannot be read, #347 keeps "" from clearing the widget.
* The list WAS read and the value is not in it — still refused. A typo'd model name is
still caught before a run fails deep in model loading.
Every message now says WHICH of the two happened, on both the refusal and the success,
and the success carries the observed reason rather than a plausible default.
Addresses the review on this PR:
* No `emptyAcceptanceUsed` reuse. The unreadable path has its own marker, arms the
promoted-write rail cross-check for the same reason #507 does, and can NEVER adopt a
rail's numeric label — that rule is justified by "the inner list was empty", which is
false of a list that exists and could not be read. The cross-check's refusals no
longer describe an unread list as empty.
* Non-empty string test, not `typeof` — "" stays refused.
* The `panel_set_widget refused …` frame is kept on the last-resort attempt's own
refusal; anything that is not a validation refusal propagates unchanged.
* The disclosure reaches the user-visible reply: the activity summary renders the
unvalidated write with a warning icon and a NOT-validated line, read as DATA off the
result rather than matched out of translated prose.
* No new tool argument, so nothing here depends on an orchestrator schema change and
no capability needs advertising — the panel decides from what it can see.
Refs #1126, #507
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d chain, and an honest disclosure Three P2s from the codex review of bf444a9. 1. A SNAPSHOT-sourced empty list no longer authorizes a blind write (#1223 x #1126). A cross-feature hole opened by two changes that are each correct alone. #1223 (v0.14.39) lets getFreshObjectInfo answer from the LAST-OBSERVED schema when both live probes go silent. This fallback read that answer as "the SERVER declares this input's option list empty" — but a snapshot is what the server said BEFORE it went quiet, retained across exactly the window in which nobody can re-fetch. Options change without a reconnect (a model downloaded while the node's own callback keeps failing), so a stale [] would newly authorize an unvalidated write. The panel now threads the provenance it already knows (setWidgetSchemaFromSnapshot) rather than letting the lib infer it. Read as a function, at the moment of decision, because which branch answered is only known after the oracle runs. UNKNOWN provenance — a probe that throws — fails closed too: nothing established must not read as live. The refusal names which of the two facts is missing, because "refresh and retry" is only actionable if the caller knows it is the schema, not their value, that could not be established. 2. A NESTED promotion is refused instead of blind-written. The rejection this fallback answers describes the IMMEDIATE promoted projection. On a nested chain the value is driven into a deeper concrete widget this path never read, whose own client-populated list may be perfectly readable — so the premise "the valid set is not knowable from here" is not established. Refusing is chosen over validating the concrete widget deliberately. Validating it would mean running membership at another level on a last-resort path, against a dynamic source carrying the same never-read-twice and stateful-callback hazards the sibling cross-check already documents — new blind-write surface in the place least able to carry it. A refusal is recoverable and names the shape; a wrong blind write into a nested chain lands on a serializing rail. Direct and single-hop promoted writes, where the widget that was read IS the one the value drives, are unaffected. 3. The disclosure no longer over-claims. For a promoted write whose parent rail has a readable non-empty list, the sibling cross-check DOES compare the value and proceeds only on membership — yet the reply and the activity summary both said nothing checked it. That is false in exactly the case where the most checking happened, and on a change whose whole value is telling the truth about what was and was not validated, an over-claimed disclosure is worse than a missing one. applyWidgetWrite now emits promoted_rail_validated when a mutated sibling's real list vouched for the value; the note and the summary scope their claim to THIS widget's own list and say so. Emitted only when true, so a reader that does not know the field sees what it saw before. The summary is proven by driving the REAL extracted describeCommand, not by scanning source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e real ladder The note wording is a user-facing disclosure about what was and was not checked, so it is asserted against a real single-hop promoted write (inner list unreadable, rail list readable and containing the value) rather than trusted. Mutating the note back to its unqualified "Nothing compared your value to anything" turns this red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… not kill Found by the mutation harness, not by review. Stubbing the panel's `schemaFromSnapshot: () => setWidgetSchemaFromSnapshot !== null` down to a constant `false` left the ENTIRE unit suite green — every behavioural test for the #1223 x #1126 gate passes `schemaFromSnapshot` in directly, so they prove the lib's decision and say nothing about whether the executor ever hands the fact over. Production would have gone back to authorizing blind writes from a stale schema with no test noticing. That is the exact dead-code shape this PR's own body warns about (#1223 v1, #757 v1): correct, fully tested, and never reached. So the wiring is asserted at the source like #718's workflow fence — including that the fallback and the reply's `schema_source` disclosure read the SAME variable, because two independent sources for one fact can disagree and then the reply describes a provenance the gate did not act on. Mutation: `schemaFromSnapshot: () => false` — SURVIVED before, KILLED now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
, and only the RAIL validates Three more P2s from the codex review of 60d70ce. 1. A CACHE-SOURCED empty list no longer authorizes a blind write (#716 x #1126). The provenance gate closed the snapshot hole but not the ordinary cache one, one layer down. #716's burst cache answers /object_info for up to 1.5s, so writes 2..N of a burst never reach the server at all -- schemaFromSnapshot() correctly returned false and the branch read a CACHED [] as current. "Not from a snapshot" is not the same as "live", and if the backend's options change externally inside that TTL the same off-list write lands. The threaded fact is widened from a boolean to a provenance -- live / cache / snapshot / none -- keyed on the SAME observedAtEpoch that already gates #1223's objectInfoSnapshot.record: captured inside the cache loader, which runs only on a miss, so a cache hit or a joined in-flight read leaves it null. The fact was already there to be reported; nothing new had to be invented. And the fallback RE-ASKS rather than merely refusing. Failing closed on every cache hit would refuse writes 2..N of an ordinary burst -- precisely the multi-widget case this change exists to serve -- so it drops the burst cache and re-calls the same oracle once, a cheap trade on a last-resort path. A live answer that still declares the list empty proceeds; one that publishes a REAL list is refused, because good provenance must not become licence to write past the very answer just fetched to check it; no live answer at all is refused naming the layer that withheld it. Nested promotions are now decided BEFORE the re-ask: the shape is already known, the answer cannot change, and refetching a whole /object_info for a write about to be refused is pure cost. 2. An empty FINAL read is #507's valid transition, not a refusal. options.values is a callback and answers per call. Throwing on the initial read and the post-refresh retry but returning [] on the FINAL one left only the unreadable acceptance enabled, so coercion fell through to #507's empty-list branch, raised a retryable emptyOptions, and refused with "the server's option list may simply be stale -- refreshing it before deciding" at the end of a ladder that had already refreshed it. By that line the LIVE server schema has already confirmed the list empty, which is #507's own precondition. The final write now enables both acceptances. Which one admitted the value is still decided at coercion time and reported from there, so an empty final read discloses empty_option_list (and correctly carries #507's rail label-adoption rule, because an empty list really does admit any scalar) while an unreadable one discloses option_list_unreadable. applyWidgetWrite names the empty acceptance on its own result, so one outcome has one name from either ladder branch. 3. Only the AUTHORITATIVE rail counts as validation. #477: one host input can reference TWO identity-authenticated widgets -- the serializing rail (_widget) and a parent-facing display proxy (input.widget), a read-only mirror. The cross-check walks both, and crediting ANY match meant a dual-projection promotion whose rail list is empty but whose PROXY list holds the value still emitted promoted_rail_validated -- the reply and the activity summary both claiming the serializing rail vouched for a value it never listed. Now only a match on parentWidget sets it. The write is unchanged and both projections are still synced; only the claim is narrowed. A proxy match proves nothing about what gets queued, and on a change whose whole value is telling the truth about what was and was not validated, an overstated disclosure is worse than a missing one. Full suite 4427 tests / 4426 pass / 0 fail / 1 todo; typecheck clean; 22/22 mutations killed, including one proving a cache-hit [] does not authorize a blind write and one proving a display-proxy match does not set promoted_rail_validated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…path was never reachable One P1 and one P2 from the codex review of 8180925. Both trace back to the #1223 snapshot work. 1. P1 — a response the backend RECONNECTED underneath is not "live". object-info-cache INTENTIONALLY still hands a retired response to its original waiter, and objectInfoSnapshot.record refuses to file it because observed !== current. The provenance labelled that same response "live" purely because the loader had run. If the old process declared an empty option list and the replacement publishes a real one, the fallback blind-writes an off-list value -- and a restart is precisely the event that changes what the server publishes. The provenance now applies the SAME test record already applies rather than inventing a second rule, and reports "reconnected" with a refusal that names the replaced process. The two can no longer disagree about whether an answer is usable as backend evidence. 2. P2 — the snapshot-specific path was UNREACHABLE. Dead code, third instance today. objectInfoSnapshot.authorize returns a DETACHED map of type names: every value is the shared frozen EMPTY_DEF, with no `input` at all. That detachment is #1223's defence against beforeRegisterNodeDef mutating defs in place, and the snapshot module's own comments name serverDeclaresEmptyComboOptions as one of the helpers that returns false on it. So the shape test is ALWAYS false against a snapshot, for every input on every node: readSchemaProvenance() never got to observe "snapshot", and the re-ask plus its diagnostic could never run. The previous test passed only because it hand-built a full starObjectInfo([]) instead of the real shape -- the third time on this branch that a test chose an input shape production never sends. Fixed on both sides. The fixture is now built by driving the REAL record + authorize, and a separate test pins the premise directly (membership survives, .input is undefined, the shape test is false) so a future change that re-attached payloads fails THERE, with a reason, instead of silently reviving a branch reasoned about as unreachable. CHOSEN BEHAVIOUR: a snapshot can NEVER authorize this fallback. Not "sometimes, if it happens to say empty" -- it structurally cannot say empty, so there is no case to gate. Teaching the snapshot to retain option lists was rejected outright: that trades a real integrity guarantee for a convenience on the rarest path in the ladder. It gets its own reachable refusal instead, because falling through to the generic end-of-ladder message would tell the caller only that their combo could not be read -- sending them to look at their value while the actual cause is a silent backend, the exact misattribution this whole change exists to stop. The fallback is restructured so evidence is established BEFORE the shape test: the three stale layers are indistinguishable to a shape test and one of them cannot answer it at all. The stale-vs-live disagreement report is now reached through `reAsked && staleDeclaredEmpty`, captured before authDefs is overwritten rather than re-derived from it. Full suite 4429 tests / 4428 pass / 0 fail / 1 todo; typecheck clean; 26/26 mutations killed, including one proving a reconnect-spanning response is not live and one that mutates object-info-snapshot.js itself so the detachment premise cannot silently change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mSnapshot option Comment-only. The option became `schemaProvenance` when a boolean could no longer express the difference between live, cache, reconnected and snapshot, but the test's own preamble still described the old boolean and the old "stub it to false" mutation. A stale comment naming a removed API is the kind of thing a later review quotes back as the repo's current contract, so it is corrected rather than left to age. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng it four ways One P1 and two P2s from the codex review of 82336d8. The P1's real content is the pattern, not the condition. 1. P1 — a response an invalidate() retired mid-flight is not live either. registerComfyNodeDefs drops the burst cache on a refresh, a pack install, or a download completing. That bumps the cache GENERATION without moving the reconnect EPOCH, and object-info-cache deliberately still hands the retired response to its original waiter. The classifier called it "live" because the loader had run, so an old schema declaring [] could authorize a blind write AFTER the refresh installed a real option list. That was the FOURTH distinct way a response turned out not to be live: a snapshot (round 1), a cache hit (round 2), a reconnect-spanning response (round 3), a generation-retired one (round 4). Four rounds, four patches, all to the same reconstructed test — the panel inferring liveness from a proxy ("did my loader body run") while the component that actually knows tracks it precisely. So this is not a fifth condition. object-info-cache.js now answers the question itself, because it owns the generation counter whose purpose is retiring requests and it decides whether each read is served, joined, or issued. readWithProvenance() returns live / cache / reconnected / retired / unknown, classified AFTER the request settles from state it owns. The reconnect epoch is the one fact it cannot know, so it is handed in as an OPAQUE stamp captured at issuance and re-checked at delivery — the cache never learns what a reconnect is, only that a caller-owned fact must not have moved. Cost, stated rather than buried: a new method on a shared, heavily-guarded component; the request-construction body lifted into a shared readInternal (yield-first, the pre-promise requestId, and the self-only finally are unchanged); read() now awaits internally instead of returning the in-flight promise, which is behaviourally identical for an async function and depended on by no test. objectInfoSnapshot.record is gated on the same verdict in BOTH routes that file into it — its own epoch test would still accept a generation-retired response, so the snapshot could retain a schema the panel itself had just superseded. graph_remove_widget had that same latent hole and is fixed too; it was not in the findings, but it files into the same snapshot. What a fifth would look like, since it was asked: a new retirement mechanism added to object-info-cache.js is now classified BY that file when it is introduced, rather than reading as "live" at every call site until someone notices. The only thing still outside that authority is a staleness source outside the cache — "snapshot" is exactly that, which is why the panel still reports it itself. 2. P2 — a nested promotion is refused before paying for a re-fetch. The chain shape is already known and no schema can make the write succeed, so on a non-live provenance the ladder was dropping the cache and downloading a multi-megabyte /object_info purely to reach a refusal it had already decided on. The nested check runs first. The test asserts the cost (no invalidate, one fetch total), not just the message. 3. P2 — a post-write failure is no longer reported as a refusal. Every "panel_set_widget refused …" message asserts that nothing was applied. Exactly one WidgetWriteError breaks that: the one raised after the graph was mutated when the rollback failed ("the graph may be in a partial state"). All four reframing sites wrapped it anyway. It now carries partialWrite: true, and a single refusalFrame() helper — the one place a WidgetWriteError becomes a refusal — propagates it verbatim. Full suite 4448 tests / 4447 pass / 0 fail / 1 todo; typecheck clean; 36/36 mutations killed, including six that mutate the cache's classifier directly and two proving a partial write is never reworded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esce the forced rereads One P1 and two P2s from the codex review of a733a66. The P1 is a different species from rounds 1-4, which is what the round-5 refactor was aiming for. 1. P1 — revalidate liveness after the recovery awaits. Rounds 1-4 asked "what KIND of response is this", and the cache now answers that with one authority. This asks "WHEN was that true". The initial /object_info read can be genuinely live, and then definitions change while refreshCombos or tryUploadAssetAccept is AWAITED. The stored string still said "live", so the blind-write path authorized from a schema that had since been superseded — bypassing a list that may have become non-empty in exactly that window. The thorough option was also the cheap one, since this file is now ours: the verdict carries the ability to re-ask itself. `provenance` is the classification at delivery, a historical fact that does not mutate; `provenanceNow()` recomputes from scratch on every call. The panel stores the QUESTION rather than the answer, so the lib's existing schemaProvenance() call — which already happens after those awaits — re-asks and gets the truth about then. Same lesson #1223 records for its snapshot: provenance describes the CONNECTION, not the bytes, so it must be re-asked rather than remembered. This generalises rather than enumerating: any future cause of expiry moves state classify() already reads. 2. P2 — coalesce the cache-bypass rereads. The risk flagged two rounds ago, arriving. `invalidate()` + re-read is global. Two cache-sourced unreadable writes reaching the fallback together each invalidated, and the second RETIRED the first's just-issued request — so a perfectly valid write was handed "retired" and refused. One caller breaking another. And nothing coalesced, so a burst paid for one multi-megabyte /object_info per caller, the exact symptom #716 exists to prevent. readFresh() replaces it: bypasses only the STORED ENTRY, never touching the generation or the ordinary in-flight slot, and keeps its own slot that concurrent forced rereads join. A joiner gets "live" rather than "cache" because the request it rides was issued by a forced read and bypassed the TTL by construction — the very thing an ordinary joiner cannot vouch for. Each joiner still captures its own stamp at join and re-checks at delivery. An invalidate() still retires a forced reread. The lib's capability is now named for the outcome it needs, not the mechanism. 3. A refusal reported an option list it never observed. The stale-vs-live disagreement refusal said "re-asking the server produced one that DOES publish a list for it". What was observed is a NEGATIVE: serverDeclaresEmptyComboOptions returned false, which is equally true when the server publishes a real list and when the re-read no longer describes this input, or this type, at all. Asserting the first as fact is the same over-claim this change exists to remove, committed by the message written to explain it. It now states the observation and names the possibilities. Full suite 4455 tests / 4454 pass / 0 fail / 1 todo; typecheck clean; 42/42 mutations killed, including one proving a verdict that expires during the recovery awaits does not authorize the blind write, and three proving concurrent forced rereads coalesce without retiring each other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hor the stamp on the record The one finding from the round-6 review of ddd02fe, and it is the risk that round flagged in its own handover: readFresh reporting "live" to a joiner was "a deliberate, documented departure… if that reasoning is wrong, it is wrong in the permissive direction". It was, and it was. The reasoning was "the request it rides bypassed the TTL by construction". True of the PAYLOAD's age, and irrelevant to the CONNECTION. Because the stamp was captured per CALLER rather than stored on the in-flight record: 1. a forced read is issued on epoch A; 2. the backend process is replaced, advancing the stamp to B; 3. a second caller reaches readFresh, reads the current epoch as its own B, and joins; 4. delivery compares B to B and reports "live" — for a response the PREVIOUS process produced. No invalidate() is needed to reach it. When the reconnect-triggered node-def refresh coalesces with one already running, the generation never moves — so the unreadable-combo fallback could blind-write an off-list value against a schema published by a backend that no longer exists. The exact class of write this PR exists to prevent, on the replacement backend. The issuance facts now live ON THE RECORD (issuedStamp, readStamp, stampUnreadable), and every joiner is classified against those. A joiner that asks for reconnect detection on a request issued WITHOUT a stamp gets "unknown", not "live": the issuance epoch was never recorded and cannot be reconstructed, and nothing established must never read as live. Same distinction provenanceNow() was built for, one level down — there it was delivery-time vs now, here it is WHICH moment the comparison is anchored at. A request cannot vouch for a connection that changed after it was issued, however fresh its payload. And labelling it is not enough: a reconnect-spanning response is now also refused a place in the cache, on BOTH paths — the ordinary one had the same hole, since a reconnect can advance the stamp without bumping the generation. Storing it would serve the dead process's schema to every later reader as "cache" for the rest of the TTL. The response is still RETURNED to the caller that awaited it, correctly labelled; it is only denied authority and storage. The same guard fails closed on an unreadable stamp — a payload nobody can attribute is not pinned for the TTL either (found by a surviving mutant, not by review). Also corrects the readFresh doc comment, which still asserted the disproved rule, and gives the return contract a Provenance typedef naming provenanceNow. Full suite 4459 tests / 4458 pass / 0 fail / 1 todo; typecheck clean; 47/47 mutations killed, including one proving a joiner on a reconnect-spanning forced read does NOT report live, and one proving such a response is never stored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dbfe7ca to
12029a6
Compare
…ue (#1293) Sixteen fixes since v0.14.43: twelve merged today (#1281, #1279, #1278, #1277, #1274, #1258, #1253, #1252, #1291, #1176, #1133, plus test-only #1178) and four that landed after the 0.14.43 branch was cut but before it merged (#1276, #1257, #1255, #1259) — none of the sixteen has shipped. Highlights: panel_get_node_info by id no longer clips the widget value it returns (#1634); a drifted graph fence is refreshed before the next call instead of after refusing it (#1209); the Settings default-backend save can no longer name a backend it never reached (#1198); a wedged orchestrator's death is recorded where the panel concludes it (#1168); a subgraph conversion that broke the graph stops reporting success (#1571); a Manager that cannot report a failure stops reading as one reporting none (#1606). Verified: 4776 tests green, 0 failures (1 todo); tsc --noEmit clean; pyproject version and PANEL_VERSION match at 0.14.44.
Refs #1126. Related, same area: #507, #240, #347, #477. Cross-feature interaction with #1223 (v0.14.39) and #716.
This does NOT close #1126. It closes a real, adjacent defect that the investigation of #1126 uncovered. The reported symptom survives — measured, not assumed. See "What this does not fix" below.
The defect this fixes
A dynamic combo's option list comes from
options.values(widget)— the node's own callback. It can mutate the widget and it can fail. When it failed, the panel had compared the value to nothing, yet answered with a refusal that reached the user as a verdict about their value:Nothing was ever compared. A node whose runtime handler takes an absolute path had the path refused as though the path were wrong.
The two outcomes are now answered differently, and both from what the panel observed:
Every message now says which of the two happened, on the refusal and on the success, and the success carries the observed reason rather than a plausible default (there are three distinct reasons; picking one would be wrong two-thirds of the time).
Guards deliberately kept
""clears the widget, and an unreadable list must not become a new door to that.unreadableAcceptanceUsed), notemptyAcceptanceUsed. It arms the panel_set_widget can never write a dynamic combo whose server-side option list is empty (StarNodes Ollama "model") #507 promoted-write rail cross-check for the same reason panel_set_widget can never write a dynamic combo whose server-side option list is empty (StarNodes Ollama "model") #507 does — a promoted write also mutates the parent's serializing rail — but it can never adopt a rail's numeric label. Adoption is justified by "the inner list admitted any scalar", true of an empty list and false of one that exists and could not be read; adopting there would write a rail's NUMBER onto a widget whose real, unread list may not hold it, and would silently replace the value the caller sent. Unreadable ⇒ verify or refuse, never substitute.panel_set_widget refused …frame is kept on the last-resort attempt's own refusal; anything that is not a validation refusal propagates unchanged.No new tool argument, so nothing here depends on an orchestrator schema change and no capability needs advertising — the panel decides from what it can see.
Review round 2 — live evidence, nested chains, honest disclosure
1. A SNAPSHOT-sourced empty list no longer authorizes a blind write (#1223 × #1126)
A cross-feature hole opened by two changes that are each correct alone. #1223 (v0.14.39) lets
getFreshObjectInfoanswer fromobjectInfoSnapshot.authorize(…).defs— the last-observed schema — when both live /object_info probes go silent. This fallback's second condition read that answer as "the SERVER declares this input's option list empty", but a snapshot is what the server said before it went quiet, retained across exactly the window in which nobody can re-fetch. Options change without a reconnect (a model downloaded while the node's own callback keeps failing), so a stale[]would newly authorize an unvalidated write against a list that is no longer empty.The panel now threads the provenance it already knows rather than letting the lib infer it, as a function read at the moment of decision — which branch answered is only known after the oracle runs, and the lib may re-ask. UNKNOWN provenance — a probe that throws — fails closed too: nothing established must not read as live. The refusal names which of the two facts is missing, because "refresh and retry" is only actionable if the caller knows it is the schema, not their value, that could not be established.
Direct and single-hop promoted writes on a live schema are unchanged. (Rounds 3 and 4 below widen this from a boolean to a five-state provenance — a snapshot is not the only stale source — and establish that a snapshot cannot answer this question at all.)
2. A NESTED promotion is refused instead of blind-written
The rejection this fallback answers describes the immediate promoted projection. On a nested chain (outer → intermediate subgraph → concrete node) the value is ultimately driven into a deeper concrete widget this path never read, whose own client-populated list may be perfectly readable — so the fallback's premise, "the valid set is not knowable from here", is not established, and an off-list value would bypass a live list that was available all along.
Refusing is chosen over validating the concrete widget, deliberately. Validating it would mean running membership at another level on a last-resort path, against a dynamic source carrying the same never-read-twice and stateful-callback hazards the sibling cross-check already documents — new blind-write surface in the one place least able to carry it. A refusal is recoverable and names the shape (it points at
panel_enter_subgraphand at the node's own option callback); a wrong blind write into a nested chain lands on a serializing rail. Direct and single-hop promoted writes, where the widget that was read is the one the value drives, are unaffected.3. The disclosure no longer over-claims
For a promoted write whose parent rail has a readable non-empty list, the sibling cross-check does compare the value and proceeds only on membership — yet the reply note and the activity summary both said nothing checked it. That is false in exactly the case where the most checking happened, and on a change whose whole value is telling the truth about what was and was not validated, an over-claimed disclosure is worse than a missing one.
applyWidgetWritenow emitspromoted_rail_validatedwhen the rail's real list vouched for the value; the note and the summary scope their claim to this widget's own list and say so. Emitted only when true, so a reader that does not know the field sees exactly what it saw before. Both are proven by driving the real code — the note through the realrunSetWidgetladder on a real single-hop promotion, the summary through the real extracteddescribeCommand— not by scanning source.Review round 3 — cache provenance, an empty final read, proxy-vs-rail
4. A CACHE-HIT empty list does not authorize a blind write either (#716 × #1126)
The provenance gate above closed the snapshot hole but not the ordinary cache one, one layer down. #716's burst cache answers
/object_infofor up to 1.5s, so writes 2..N of a burst never reach the server at all —schemaFromSnapshot()correctly returned false, and the branch read a cached[]as current. "Not from a snapshot" is not the same as "live." If the backend's options change externally inside that TTL, the same off-list write lands.The threaded fact is therefore no longer a boolean but a provenance —
"live"/"cache"/"snapshot"/"none"— keyed on the sameobservedAtEpochthat already gates #1223'sobjectInfoSnapshot.record: captured inside the cache loader, which runs only on a miss, so a cache hit or a joined in-flight read leaves it null. The fact was already there to be reported; nothing new had to be invented.And the fallback re-asks rather than merely refusing. Failing closed on every cache hit would refuse writes 2..N of an ordinary burst — precisely the multi-widget case this PR exists to serve — so it drops the burst cache (
invalidateObjectInfoCache) and re-calls the same oracle once, which is a cheap trade on a last-resort path. Then:Nested promotions are now decided before the re-ask: the shape is already known, the answer cannot change, and re-fetching a whole
/object_infofor a write about to be refused anyway is pure cost.5. An empty FINAL read is #507's valid transition, not a refusal
options.valuesis a callback and answers per call. If it threw on the initial read and the post-refresh retry but returned[]on the final one, onlyacceptUnreadableComboOptionswas enabled — so coercion fell through to #507's empty-list branch, raised a retryableemptyOptions, and refused with "the server's option list may simply be stale — refreshing it before deciding" at the end of a ladder that had already refreshed it. By that line the live server schema has already confirmed the list empty for this input, which is #507's own precondition, so this was a valid transition being rejected with a message that contradicted the ladder it sat at the end of.The final write now enables both acceptances. Which one admitted the value is still decided at coercion time and reported from there, so the disclosure names what happened rather than what the call hoped for: an empty final read reports
empty_option_list(and correctly carries #507's rail label-adoption rule, because an empty list really does admit any scalar), an unreadable one reportsoption_list_unreadable.applyWidgetWritenow names the empty acceptance on its own result, so one outcome has one name from either ladder branch.6. Only the AUTHORITATIVE rail counts as validation
#477: a single host input can reference two identity-authenticated widgets — the serializing rail (
_widget) and a parent-facing display proxy (input.widget), a read-only mirror. The cross-check walks both, and crediting any match meant a dual-projection promotion whose rail list is empty but whose proxy list holds the value still emittedpromoted_rail_validated— the reply and the activity summary both claiming the serializing rail vouched for a value it never listed.Now only a match on
parentWidgetsets it. A proxy match stays silent and the write is reported as fully unvalidated, which is what it is with respect to what actually gets queued. The write itself is unchanged — both projections are still synced — only the claim is narrowed. On a change whose whole value is telling the truth about what was and was not validated, an overstated disclosure is worse than a missing one.Review round 4 — a reconnect-spanning schema, and a branch that could never run
7. A response the backend reconnected underneath is not "live" (P1)
object-info-cacheintentionally still hands a retired response to its original waiter, andobjectInfoSnapshot.recordrefuses to file it becauseobserved !== current. The provenance above labelled that same response"live"purely because the loader had run. If the old process declared an empty option list and the replacement publishes a real one, the fallback blind-writes an off-list value — and a restart is precisely the event that changes what the server publishes.The provenance now applies the same test
recordalready applies (observedAtEpoch === backendReconnectEpoch) rather than inventing a second rule, and reports"reconnected"with a refusal that names the replaced process. The two can no longer disagree about whether an answer is usable as backend evidence.8. The snapshot-specific path was UNREACHABLE — dead code, and the test hid it
objectInfoSnapshot.authorizereturns a detached map of type names: every value is the shared frozenEMPTY_DEF, with noinputat all. That detachment is #1223's defence againstbeforeRegisterNodeDefmutating defs in place (and it keeps ~5.4MB off each connection) — the snapshot module's own comments nameserverDeclaresEmptyComboOptionsas one of the helpers that returnsfalseon it.So the shape test is always false against a snapshot, for every input on every node.
readSchemaProvenance()never got to observe"snapshot": the re-ask and its diagnostic were dead code. The round-3 test passed only because it hand-built a fullstarObjectInfo([])instead of the real shape — the third time on this branch that a test chose an input shape production never sends.Fixed on both sides:
record+authorize, never hand-rolled, and a separate test pins the premise directly — membership survives,.inputisundefined, the shape test returnsfalse. A future change that re-attached payloads to the snapshot fails there, with a reason, instead of silently reviving a branch that was reasoned about as unreachable.The fallback is also restructured so evidence is established before the shape test: the three stale layers (
cache,reconnected,snapshot) are indistinguishable to a shape test, and one of them cannot answer it at all. The stale-vs-live disagreement report is now reached throughreAsked && staleDeclaredEmpty, which is a fact captured beforeauthDefsis overwritten rather than re-derived from it.Review round 5 — stop re-deriving liveness; let the cache answer
9. A response an
invalidate()retired mid-flight is not live either (P1)registerComfyNodeDefsdrops the burst cache on a refresh, a pack install, or a download completing. That bumps the cache generation without moving the reconnect epoch — andobject-info-cachedeliberately still hands the retired response to its original waiter. The classifier called it"live"because the loader had run, so an old schema declaring[]could authorize a blind write after the refresh installed a real option list.That was the fourth distinct way a response turned out not to be live:
invalidate()retired it mid-flightFour rounds, four patches, each correct and each to the same reconstructed test. So the fix is not a fifth condition. The panel was inferring liveness from a proxy (did my loader body run?) while the component that actually knows tracks it precisely:
object-info-cache.jsowns the generation counter whose entire purpose is retiring requests, and it decides whether each read is served, joined, or issued.It now answers the question itself:
provenanceis"live" | "cache" | "reconnected" | "retired" | "unknown", decided after the request settles from state the cache owns. The reconnect epoch is the one fact the cache cannot know, so it is handed in as an opaque stamp captured at issuance and re-checked at delivery — the cache never learns what a reconnect is, only that a caller-owned fact must not have moved.read()keeps its old contract (payload only), so no other consumer changed.What it costs, stated plainly:
readInternal; the load-bearing invariants (yield-first before the slot assignment, therequestIdcaptured before the promise, thefinallyreleasing only its own slot) are unchanged and still covered by that file's existing tests.read()now awaits internally rather than returning the in-flight promise directly. Behaviourally identical for anasyncfunction, and no test depended on promise identity — but it is a real change to a hot path.objectInfoSnapshot.recordis now gated on the same verdict in both routes that file into it.record's own epoch test would still have accepted a generation-retired response, so the snapshot could retain a schema the panel itself had just superseded.record's checks stay as defence in depth for callers with no verdict to offer.What a fifth would look like — and this is the payoff: a new retirement mechanism added to
object-info-cache.jsis classified by that file, at the point it is introduced, instead of reading as"live"at every call site until someone notices. The one thing that would still escape is a staleness source outside the cache, and there the honest answer is that no single authority can help:"snapshot"is exactly that case, which is why the panel still reports it itself.I also fixed the same latent hole in
graph_remove_widget, which files into the same snapshot and had the sameobservedAtEpoch !== nullgate. Not in the findings; found while removing the pattern.10. A nested promotion is refused before paying for a re-fetch (P2)
The chain shape is already known and no schema can make the write succeed, so on a
cache/reconnected/retired/snapshotprovenance the ladder was dropping the burst cache and downloading a multi-megabyte/object_infopurely to reach a refusal it had already decided on. The nested check now runs first. The test asserts the cost directly (invalidated === 0, one fetch total), not just the message.11. A post-write failure is no longer reported as a refusal (P2)
Every
panel_set_widget refused …message asserts more than the text it wraps: that this was a refusal, so nothing was applied. Exactly oneWidgetWriteErrorbreaks that — the one raised after the graph was mutated when the rollback failed to restore it ("the graph may be in a partial state"). All four reframing sites were wrapping it anyway, telling the caller nothing happened about a graph that had changed.That error now carries
partialWrite: true, and a singlerefusalFrame()helper — the one place aWidgetWriteErrorbecomes a refusal message — propagates it verbatim instead. Telling a caller "nothing was applied" when something was is the exact defect this PR family exists to avoid.Review round 6 — a verdict is about a moment, and it expires
12. Revalidate liveness after the recovery awaits (P1)
Rounds 1–4 all asked what kind of response is this, and the round-5 refactor gave that one authority. This is a different question: when was that true. The initial
/object_inforead can be genuinely"live"— and then definitions change whilerefreshCombosandtryUploadAssetAcceptare awaited. The stored string still said"live", so the blind-write path authorized from a schema that had since been superseded, bypassing a list that may have become non-empty in exactly that window.The thorough fix was the cheap one, because I already own the file: the verdict now carries the ability to re-ask itself.
provenanceis the classification at delivery — a historical fact that does not mutate.provenanceNow()recomputes from scratch on every call. The panel stores the question, not the answer, and the lib's existingschemaProvenance()call — which already happens after those awaits — now re-asks and gets the truth about then.This is the same lesson #1223 records for its snapshot: provenance describes the connection, not the bytes, so it has to be re-asked rather than remembered.
13. Coalesce the cache-bypass rereads (P2)
The risk I flagged two rounds ago arrived.
invalidate()+ re-read is global: two cache-sourced unreadable writes reaching the fallback together each invalidated, and the second retired the first's just-issued request — so a perfectly valid write was handed"retired"and refused. One caller breaking another. And nothing coalesced, so a burst paid for one multi-megabyte/object_infoper caller — the exact symptom #716 exists to prevent, reintroduced on the recovery path.readFresh()replaces it: it bypasses only the stored entry, never touching the generation or the ordinary in-flight slot, and keeps its own slot that concurrent forced rereads join. A joiner gets"live"rather than"cache"because the request it rides was issued by a forced read — it bypassed the TTL by construction, which is precisely what a joiner normally cannot vouch for. Each joiner still captures its own stamp at join and re-checks at delivery, so a reconnect landing on one caller is reported to that caller alone. Aninvalidate()still retires a forced reread, because a reread issued before an invalidation is exactly as superseded as an ordinary one.The lib's capability is now named for the outcome it needs (
refetchObjectInfoLive) rather than the mechanism, so the cache decides how to get a fresh answer without disturbing anyone else.14. A refusal that reported an option list it never observed
The stale-vs-live disagreement refusal said "re-asking the server produced one that DOES publish a list for it." What was actually observed is a negative:
serverDeclaresEmptyComboOptionsreturnedfalse. That is equally true when the server publishes a real list and when the re-read no longer describes this input — or this type — at all. Asserting the first as fact is the same over-claim this PR exists to remove, committed by the message written to explain it. It now states the observation and names the possibilities.Review round 7 — the joiner's stamp belonged to the request, not the caller
15. Preserve the request stamp when coalescing forced reads (P1)
This is the risk the previous round flagged in its own handover, and it was wrong in the direction that round predicted — the permissive one.
readFreshreported"live"to a joiner on the reasoning that the request it rides bypassed the TTL by construction. That is true of the payload's age, and irrelevant to the connection. The stamp was captured per caller rather than stored on the in-flight record, so:readFresh, reads the current epoch as its own B, joins;"live"— for a response the previous process produced.No
invalidate()is needed to reach it: when the reconnect-triggered node-def refresh coalesces with one already running, the generation never moves. The unreadable-combo fallback would then blind-write an off-list value against a schema published by a backend that no longer exists — the exact class of write this PR exists to prevent, on the replacement backend.The issuance facts now live on the record (
issuedStamp,readStamp,stampUnreadable), and every joiner is classified against those. A joiner that asks for reconnect detection on a request issued without a stamp gets"unknown"rather than"live": the issuance epoch was never recorded and cannot be reconstructed, and nothing established must never read as live.It is the same distinction
provenanceNow()was built for, one level down — there it was delivery-time versus now, here it is which moment the comparison is anchored at. A request cannot vouch for a connection that changed after it was issued, however fresh its payload.And labelling it is not enough. A reconnect-spanning response is now also refused a place in the cache, on both paths — the ordinary one had the same hole, since a reconnect can advance the stamp without bumping the generation. Storing it would serve the dead process's schema to every later reader as
"cache"for the rest of the TTL, turning one badly-timed response into a second and a half of them. The response is still returned to the caller that awaited it, correctly labelled; it is only denied authority and storage.Reviewer note: read
object-info-cache.jsfirstThis file is the highest-risk part of the PR and should be reviewed as such. It has now been modified in three consecutive review rounds, and its own header documents three separate review-found bugs in roughly sixty lines of request logic — a synchronously-throwing loader corrupting the in-flight slot, an outcome wrapper being cached as a success, and a joined read reporting as issued. Rounds 5, 6 and 7 each added to that count: the delivered-verdict refactor, the coalescing forced-reread slot, and the joiner stamp above.
The concentration is not accidental. This is where "how did we come to believe this schema" is decided, and every review round has found that the previous answer was under-specified in the permissive direction. Concretely, the parts most worth an independent check:
inflightandfreshInflight) and their interaction withinvalidate(), which must retire both;finallyreleasing only its own slot;verdict()is called with the record's issuance facts on the forced path and the call's own on the ordinary one, because on the ordinary path the issuer is the only caller that can be live.What this does NOT fix, and why #1126 stays open
#1126's tell is
Valid options (1): empty— a list that read perfectly well and held one placeholder. I drove the realrunSetWidgetagainst that literal shape:["empty"], server["empty"]["empty"], server[]options.values()throws, server[]fetched liveoption_list_unreadable)Only C is what this PR fixes. The reporter's combo is case A, so their symptom survives and their workaround (copying the file into ComfyUI's input directory) is still required.
That is not a gap to be papered over here. Nothing observable from the panel separates "a placeholder the node's JS inserted" from "real options the server could not enumerate" — not the option names (
Noneis a real, meaningful option on plenty of nodes: a LoRA slot's "no lora"), and not the server's declaration, which also covers the StarNodes combo whose own JS populates a list of REAL models where an off-list value is a typo worth refusing. Inferring it was tried and broke three #507 invariants that are correct.Case A needs the caller, because the caller is the one who knows the node accepts values its dropdown cannot list.
Follow-up that does close #1126
Branch:
fix/1126-assertion-approach-dr1133A per-call
allow_unlistedassertion onpanel_set_widgetthat converts a would-be combo refusal into an unvalidated, disclosed write. It closes case A. It is complete and verified — full suite green, 8/8 mutations killed, including the #240 regression (a rail listing the NUMBER4444must not replace the caller's string"4444") and #347's clear-to-""rule.It is not landing yet because it is inert:
panel_set_widget's argument schema lives in comfyui-mcp, andsrc/orchestrator/panel-tools.tscurrently neither declaresallow_unlistedin the zod schema (the tool isadditionalProperties: false, so a client that sends it gets-32602 Unrecognized key) nor forwards it intoctx.call({ cmd: "graph_set_widget", … }). Both are required upstream before the panel half can ever fire.Sequencing rule: the panel-side half lands now; the caller-side half lands when the caller can actually send it. Shipping a route that cannot fire is a trap this repo has now hit three times (#1223 v1 recorded a snapshot on a path that never runs at startup; #757 v1 tested an object shape while production sends a JSON string; and round 4 of this PR, where the snapshot branch could never be entered because the test hand-built a schema shape production never produces). Each looked correct, each was fully tested, each was dead.
Verification
npm run test:unit— 4455 tests, 4454 pass, 0 fail, 1 todo (includesscripts/i18n-check,i18n-render-check,check-tool-vocabulary, andcheck-panel-scope— every name in the panel bundle resolves)npm run typecheck— cleanMerge state against
main: CLEAN.origin/main(through v0.14.41 / #1478) is merged in; the only shared file iscomfyui-mcp-panel.jsand it auto-merged, with both sides re-verified afterwards.Caveat, recorded rather than hidden:
manager-install.test.mjs#671 verifyInstalled …is a wall-clock budget test that intermittently fails under full-suite parallel load on a Windows dev box (observed 24–53s against its budget). It is pre-existing and unrelated — it fails identically on this branch tip with all panel_set_widget cannot set an absolute path on a dynamic file combo even though the node accepts it at runtime #1126 changes stashed, and passes in isolation on this exact tree. It did not fire on the runs recorded above. Flagged as a possible real Windows-perf signal worth its own issue, not addressed here.Mutation tested — 36 applied to this design, 36 killed. Restored from an in-memory snapshot (never
git checkout), with every anchor asserted to occur exactly once first, because this checkout is CRLF and a silently-missed LF anchor reads exactly like a kill.graph_set_widget THREADS the schema provenance into runSetWidgetprovenanceNowreplays the delivered verdict instead of recomputing ita verdict EXPIRES — provenanceNow re-answers, it does not replaygraph_set_widget THREADS the schema provenance into runSetWidgetreadFreshbumps the generation — one caller's reread retires another's requesttwo concurrent readFresh callers COALESCE and neither retires the othertwo concurrent readFresh callers COALESCE and neither retires the otherreadFreshis satisfied by the stored entry — the bypass does not bypasstwo concurrent readFresh callers COALESCE and neither retires the otherinvalidate()again instead of the coalescing rereadgraph_set_widget THREADS the schema provenance into runSetWidgeta re-ask that finds a REAL list refuses instead of writing blindinvalidate()mid-flight reads as livean invalidate() DURING the request retires it — 'retired', not livea stamp that MOVES mid-flight is 'reconnected', and outranks a retirementa SERVED cache hit and a JOINED read are both 'cache'a SERVED cache hit and a JOINED read are both 'cache'a THROWING stamp establishes nothing — 'unknown', never livean ISSUED request that nothing retired is livegraph_set_widget THREADS the schema provenance into runSetWidgetgraph_set_widget THREADS the schema provenance into runSetWidget"retired"drops out of the recognizer — a superseded response reads as unknowna RETIRED response is not livea PARTIAL write is never reworded into a 'refused' framea PARTIAL write is never reworded into a 'refused' framea RECONNECT-SPANNING response is not livean UNREADABLE option list writes the path and discloses that nothing checked itUNKNOWN provenance fails closed — a throwing probe is not a live onea SNAPSHOT can never authorize the blind write, and says why"reconnected"drops out of the recognizer — a replaced process reads as unknowna RECONNECT-SPANNING response is not livegraph_set_widget THREADS the schema provenance into runSetWidgetgraph_set_widget THREADS the schema provenance into runSetWidgeta SNAPSHOT can never authorize the blind write, and says whyEMPTY_DEFcarries the real def throughthe real snapshot map holds NAMES ONLY — it cannot answer an option-list questiona CACHE-HIT empty list does NOT authorize a blind write eithera cache hit is RE-ASKED, and a live empty answer authorizes the writerecordis ignoredgraph_set_widget THREADS the schema provenance into runSetWidgeta re-ask that finds a REAL list refuses instead of writing blindgraph_set_widget THREADS the schema provenance into runSetWidget[]answer is refuseda stateful callback that finally answers [] takes #507's acceptance, not a refusalapplyWidgetWritestops naming the empty acceptance, so the reply cannot report ita stateful callback that finally answers [] takes #507's acceptance, not a refusalthe unreadable fallback REFUSES a nested promotion rather than writing blindnestedPromotionis recorded but never becomes true — a dead flagthe unreadable fallback REFUSES a nested promotion rather than writing blindan UNREADABLE option list writes the path and discloses that nothing checked itthe reply note does not claim 'nothing checked it' when the RAIL didapplyWidgetWritenever emitspromoted_rail_validatedthe reply note does not claim 'nothing checked it' when the RAIL dida rail that VALIDATED the value is reported as having done soa value the parent RAIL validated is NOT reported as wholly uncheckeda DISPLAY PROXY match does not claim the serializing RAIL validated the valueserverDeclaresEmptyComboOptionsrequirement on the unreadable patha failed live read is NOT licence to ignore a list the SERVER publishesthe unreadable acceptance ARMS the rail cross-check but NEVER adopts a rail labelF1-d is the one that mattered most: it survived the first harness run. Nothing proved the panel actually hands the lib the provenance, so stubbing the wiring to a constant left every behavioural test green while production authorized blind writes from stale schemas again — the exact dead-code shape this PR's own body warns about. A source-level wiring assertion (like #718's fence) now pins it, and C-3, C-5 and X-1 ride on the same test: every branch of the oracle must record its provenance,
"live"must stay gated on both factsobjectInfoSnapshot.recordis gated on (an epoch captured inside the cache loader, and that epoch still matching), and the lib must be able to drop the cache before re-asking.S-2 is the guard against the round-4 defect recurring: it mutates
object-info-snapshot.jsitself, so if the snapshot ever stopped detaching, the fixture test fails with a reason instead of the#1126branch quietly changing shape underneath a suite that would still be green.One mutation is recorded as an equivalent mutant rather than a hole. An earlier form of F1-c2 (
return "live" || false || p === …) survived, and correctly so: it left the ternary condition truthy, so the reader still returnedpunchanged. It is noted in the harness so a later run does not re-chase it, and replaced with a form that actually short-circuits the reader. The round-3 mutationsC-3/X-1were retired outright — they anchored the hand-rolled panel classifier that round 5 deleted, and their subject matter is now covered byA-1…A-6inside the cache.Draft on purpose — review and merge are the maintainer's.