The engine handlers already enforce _IDENT_RE on output_var / var_binding
/ fixed values[].name via validate_payload, wired into every
create/update/import through _validate_payload_for_type (returns 400 on
ValueError). Existing tests cover leading-digit / dunder / too-long /
empty cases; add the explicit comma scenario the user reported so the
exact "no settle-delimiter in a var name" contract is regression-proof
for all three module types. No production change — server gate already
existed; this characterizes + locks it.
- Cross-node drag MOVE-becomes-COPY race: ContextWidget.onDrop sync-claims
consumedBy before any await so the source widget's dragend reads the
set value. dragend filters by _uid (idempotent) instead of stale
srcIdx — fixes the secondary issue where the backup pendingHandoffs
queue + dragend double-removed an adjacent target wildcard.
- pendingHandoffs queue in drag-store: belt-and-suspenders source-side
cleanup that runs even when dragend skips (constraint rows inside the
bundle frame were the trigger).
- Conflict scanner constraint_orphan_target slot allocator: per-target
sorted local positions + remaining downstream count. Each constraint
claims smallest local slot > own index, else a downstream slot. Fixes
duplicated constraints with target downstream cross-node + correctly
flags when only one local target sits BEFORE the constraint.
- collectDownstreamWildcardUuids: per-instance occurrences (not Set-
deduped) so duplicated downstream targets each count as a separate
claimable slot.
- RichTextPreview + ConstraintInstanceModal: cross-Context refs fall
back to preview-resolver cache (sub_categories, option values,
options_by_id, hasNullOption) so the canvas modal renders the actual
wildcard name, sub-cat list, and chip suffix instead of "source"/
"target" placeholders + stale matrix keys.
- Preview-resolver schema extended with subCategories / optionValues /
optionsById / hasNullOption populated from embed-bundle response.
- Constraint save hydrate: wp_api modules + bundles endpoints fill
exception source_value / target_value from source_id / target_id by
looking up the wildcard's option list before validation. Heals
library rows that stored only ids.
- TestRunner constraint table uses RichTextPreview for column headers
+ row values so nested @{uuid} refs render as chips.
- Inverted null filter UI: SubcategoryFilterPicker "Exclude null"
checkbox + RefChip "!null" suffix render.
- Bundle size budget bumped 330 KB to 340 KB.
- Drop stale CHECKPOINT-*.md docs.
Lands the 15-bug catalogue from the 2026-05-19 creative-QA pass.
API layer (wp_api/):
- New _validators.py centralises meta + identifier + body-size checks
so modules and bundles share the same shape rules
- SPA catch-all now returns JSON 404 for unknown /wp/api/* paths
instead of the SPA shell (debugging hostile previously)
- create/update/import_from_workflow validate meta (name/desc/tags/
category_id) before hitting the repo; tag list now rejects non-str
entries (was silently stored, breaking the SPA type contract)
- IntegrityError on dangling category_id surfaces as 400, not 500
- Optional If-Match header on PUT enables opt-in optimistic
concurrency — stale version → 409 instead of silent overwrite
- Bundle name collisions auto-suffixed with (copy)/(copy N) mirroring
the modules-side forkModule rule
- Bundle children deduped by id at create + update time
- Deleting a module flags meta.orphaned=true on every bundle child
whose snapshot referenced it so the SPA can render a stale badge
Engine layer (engine/modules/*_handler.py):
- wildcard: reject empty + __dunder var_binding, cap at 64 chars,
reject duplicate option ids
- combine: reject empty template, cap template at 8000 chars, cap
output_var at 64, reject __dunder output_var
- fixed_values: cap value name length, reject __dunder names, reject
duplicate names across rows (was silent last-write-wins)
- derivation: reject duplicate rule ids (was nondeterministic for
disabled_rule_ids / rule_order_override lookups)
Tests:
- New test_qa_hardening.py exercises every endpoint-level fix
- Extended test_payload_validation.py parametrised rejection matrix
- New test_spa_fallback.py case for /wp/api/* JSON 404
655 pytest pass, 1665 vitest pass, ruff clean.
Phase D — alias removal:
- Dropped PUT /wp/api/modules/{id}/payload + library_save.py module.
PushToLibraryModal now calls the canonical PUT /wp/api/modules/{id}
with flattened meta fields (name, description, tags) instead of the
nested meta sub-object the alias accepted. Lifetime ended at one
workflow review cycle; no external consumers exist.
- Updated test_save_to_library_unified.py and test_payload_validation
.py to point at the canonical route. Deleted test_library_save_
endpoint.py.
Bug 3b — modal silent on description/tags:
- Workflow rows only carry meta.name + meta.library_name at insert
time, so opening PushToLibraryModal on an existing library-tracked
row left description/tags blank even when the library row had them.
- Added seedFromLibrary() that fetches /wp/api/modules/<id> on modal
open and fills in any blanks. Respects in-modal edits: only writes
to fields that are still empty when the response lands.
Bug 3c — wildcard binding override didn't surface as outer mod state:
- Combine already checked inst.variable_binding in isModified();
wildcard didn't. Brought the wildcard branch in line so renaming
the binding on a wildcard surfaces the same orange dot a combine
rename does. INSTANCE_FIELDS_PER_KIND already listed binding on
both kinds — this row brings the outer dot to match the modal's
mod gauge.
Bug 3a — reset-icon semantics after Update existing:
- The right-click push-to-library flow updated the library entry but
never refreshed the workflow row's meta.library_name; the per-field
reset-to-library button then offered to revert to the pre-save
name. Both right-click onPushSaved and the in-modal
onPushToLibrarySaved now stamp library_name alongside name +
payload_hash on Update mode so reset reflects the new library
state.
Tests: 1665 SPA + 59 Python passing. Ruff + lint clean.
Bundle size: 306499 / 307200 (701 bytes headroom).
Live QA pending: needs ComfyUI restart to pick up the route removal +
Python validator paths.
Bug 6 fix: wildcard + fixed_values handlers had no validate_payload
method, so the base no-op was silently accepting any shape on every
write boundary. Verified live: PUT /payload with {options: 'not-an-array'}
returned 200 and persisted the garbage; POST accepted {options: 42};
cross-type pollution (combine-shaped payload PUT to a wildcard) also
succeeded with 200.
Changes:
- engine/modules/wildcard_handler.py: validate_payload checks options
list shape + per-option value/weight types + non-negative weights +
optional sub_category strings + var_binding identifier + sub_categories
list-of-strings.
- engine/modules/fixed_values_handler.py: validate_payload checks values
list shape + per-value name/value types + identifier rule on non-empty
names.
- wp_api/modules.py: validates payload on POST /modules, PUT /modules/{id}
(when payload field is present), and POST /modules/import-from-workflow.
Shared _validate_payload_for_type helper.
- wp_api/bundles.py: validates every child snapshot's payload on POST
/bundles and PUT /bundles/{id} when children[] is in the body — closes
the side door that let bundle writes bypass per-module validation.
- tests/wp_api/test_payload_validation.py: 26 cases covering happy paths
+ malformed payloads per kind, cross-type pollution, import-from-
workflow, and bundle-children side door.
- tests/wp_api/test_bundles_crud.py: fixture combine child gained the
required output_var field (it was implicitly invalid before).
Plus docs/superpowers/specs/2026-05-19-save-to-library-rethink-design.md:
the broader four-phase plan that addresses Bugs 1-5 (meta drop, bundle
propagation, fork discoverability, modal button visibility, inline
modules can't save). Phase A (this commit) plugs the validation hole.
Phases B-D will land separately after design review.
Live QA reproduction order remains in the design doc.