Option sub_category -> sub_categories[] (default []). Both pool filters (instance category_filter, nested @{} ref) become boolean expressions (and/or/not/parens; comma=or) matched against an option's tag set via the shared engine/syntax/subcat_filter matcher, applied BEFORE constraints. Exclude-null is a separate flag: instance exclude_null + the trailing !null ref marker, replacing the old 'null' keyword.
4-segment ref grammar @{uuid#name:expr!null} single-sourced across tokenize.py/scan.py/pipeline.py/_constraints.py (! excluded from name/expr captures); tokenizer REF meta now carries filter_expr + exclude_null instead of a comma-split list. scan.py findall unpack sites updated for the 4th group.
validate_payload: per-option sub_categories[] must be in the registry; optional tag_groups (UI-only axes, members in-registry, <=1 group); registry names validated via subcat_filter.validate_subcat_name (rejects whitespace, boolean/ref chars, reserved words incl. null). wp_api/_validators delegates to the same function.
Constraints stay alive via a primary-tag stopgap: _apply_constraint_to_options + _record_pick use sub_categories[0] as the matrix key; true multi-tag matrix matching is SP3. Cascade fix_subcat_delete/rename + orchestrator scrub the per-option list; instance_schemas category_filter -> string. Existing engine tests migrated to the v2 shape; added tests for the ref regex, payload validation, and multi-tag pool filtering.
Workflow drift/missing now runs through the shared classifyOne verdict: a same-id, DIFFERENT-kind library row surfaces as a distinct 'clash' state (violet badge) instead of a misleading drift/missing dot, and Refresh is suppressed + guarded in mergeRefresh so it can never clobber an unrelated item. Hybrid content-key: the workflow path compares payload_hash (drift semantics unchanged) while the import picker keeps fingerprint; classifyOne is content-key agnostic. /wp/api/modules/hashes now returns {type, payload_hash} per id; drift-store map + AppLayout fingerprint + setLibraryHash updated to the object shape.
Publishing a local row to community never linked the row back to the
resulting post, so: no community pill, no update-available tracking,
and the re-publish version-bump flow was unreachable (origin slug was
always null -> publishMode could only ever be 'create').
Adds the writeback path:
- ModuleRepository / BundleRepository.set_community_origin(id, slug,
version): stamps community_post_slug + community_version_number
WITHOUT a version bump or fingerprint change -- origin metadata is
not runtime-affecting, so a published row stays byte-identical to
what consumers installed.
- wp_api: POST /wp/api/modules/{id}/community-origin + the bundles
twin. Validates {post_slug: str, version_number: int}.
- api client: modules.setCommunityOrigin + bundles.setCommunityOrigin.
- main.ts host bridge: new optional `markPublished(localId, kind,
slug, version)` method. Calls the right setCommunityOrigin, then
refreshes both catalogs so the pill appears without a reload.
Optional + try/catch-returns-false so a writeback miss never breaks
the embed's publish-success path. No ABI bump -- adding a method is
non-breaking; the embed guards on its presence.
Tests: set_community_origin stamps slug+version, does NOT bump
version/fingerprint, raises on missing row.
Pairs with the community embed commit that calls markPublished after
createPost / createVersion.
Wires the content_rating axis end-to-end on the SPA + HTTP layer:
- TS types: ModuleRow / BundleRow / *CreateInput / *UpdateInput +
InstallOrigin all gain a `content_rating?: 'safe' | 'nsfw'` field.
- install.ts: when `opts.origin.content_rating` is set, every entity
going in has its content_rating stamped -- the embed's community
install passes 'nsfw' through to the engine without needing a
follow-up fetch.
- IdentityCard: adds a Toggle in a new "Content rating" field. Same
component is the source for all 6 module editors + BundleEditor,
so the toggle lives in one place. A `showContentRating` opt-out
lets TemplateEditor hide the field (templates table doesn't carry
the column).
- All 6 module editors (Fixed, Wildcard, Combine, Constraint,
Derivation, Bundle) plumb a `contentRating` ref through load,
save, and IdentityCard binding.
- wp_api modules + bundles: POST/PUT endpoints accept content_rating
in the body and forward to the repository.
- ModuleListView: renders a new `wp-row-community-pill--nsfw` pill
on rows where row.content_rating === 'nsfw'. Uses the harmonized
primitive from b5ce9e4 -- the comment block there explicitly
seeded this variant.
Introduces a persisted prompt-template library: migration 012 creates the
templates table (name/description/category_id FK/tags/is_favorite/
template_string), TemplateRepository provides CRUD with partial-update
sentinel semantics, and wp_api/templates.py exposes list/get/create/update/
delete/favorite with FK violations mapped to 400. Bumps the migration-head
version assertions to 12.
Accumulated branch work that was uncommitted alongside the pair-badge feature in the prior commit. Captured as a single sweep commit so the branch can merge cleanly; future work should commit per-feature to avoid this.
- 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.
Replace the substring-match `"not found" in error` line in
`import_undo` with an explicit `get_undo_entry` existence check
before calling `undo_import`. The handler now returns 404 only
when the undo row is structurally absent, mirroring the typed-
exception pattern (`ModuleNotFound`, `BundleNotFound`,
`CategoryNotFound`) used elsewhere in wp_api. A future engine
wording change can no longer silently degrade the 404 to 400.
Both reads share a single `db_session` so the existence check
and the destructive undo stay in one transaction context.
Add two new POST handlers in wp_api/import_export.py wrapping the
engine.importer.commit_import / undo_import functions from Task 13.
- /wp/api/import/commit: body {adds, replaces, renames}; 200 with
{ok, undo_entry_id, summary} on success; 400 on contract/DB errors.
- /wp/api/import/undo: body {undo_entry_id}; 200 with {ok: True} on
success; 404 on unknown entry; 400 on shape/other errors.
- Both guard request body via validate_body_size (5 MB cap).
- Engine returns undo_id; handler renames to undo_entry_id at the
HTTP boundary to keep the locked endpoint contract decoupled from
the engine field name.
- Legacy export_bundle + import_bundle + new export_build handlers
remain unchanged.
Wraps engine.exporter.build_export_payload behind an HTTP route so the
SPA can drive a 7-bucket export with picker-selected UUID sets. Body
shape uses 7 keys (bundle_uuids, wildcard_uuids, fixed_values_uuids,
combine_uuids, derivation_uuids, constraint_uuids, category_uuids);
each key is optional and defaults to []. Mis-typed UUIDs are silently
dropped by the exporter (cross-bucket isolation guarantee). Response
is the exporter payload returned directly, matching the legacy
/wp/api/export shape convention.
The A-side write check (previous commit) prevents building A → B → C from A's perspective, but B can be updated unilaterally. Without a matching check on B's update, B could gain bundle children of its own and silently demote every parent A's reference into a tier-3 structure on read — the resolver would happily attach the new bundle children, the SPA pre-flatten would see two levels of bundles below A, and the tier-2 ceiling would be effectively gone.
Lock the ceiling from both sides:
- _find_parents_referencing scans the library for any bundle whose children contain a bundle ref pointing at the row being updated.
- _validate_parents_compatible runs only when the patch carries a key AND that new list contains at least one bundle entry — rename / recolor / leaf-only edits skip it entirely.
- 409 Conflict (not 400) because the failure is a state-vs-write conflict, not malformed input. Error body cites the offending parents (up to 3, then '… N more') so the SPA can route the user to detach references first.
5 new tests cover the 409 path, the no-parents allow path, the leaf-only-edit allow path, the metadata-only allow path, and the multi-parent listing in the error message. Full wp_api suite green (174/174).
Bundles can now contain other bundles as children — but stored as id-only POINTERS, not deep snapshots. The reference model sidesteps the drift cascade that a snapshot model would introduce (parent goes stale every time the inner bundle is edited).
Storage shape for a bundle child:
{ 'id': <ref-id>, 'type': 'bundle', 'name'?: str, 'color'?: str }
Display fields (name, color) are cached so the parent renders a sensible placeholder if the referenced bundle is later deleted; the actual children belong to the referenced row.
Three pieces wired into POST + PUT /wp/api/bundles:
- _normalise_bundle_children strips every field except the reference pointers off bundle-typed children before insert, so a SPA round-tripping the GET-expanded shape doesn't accidentally persist a stale snapshot.
- _validate_bundle_refs rejects three cases against the live repo state: self-include (A → A, PUT only), missing reference (id not in repo), and tier-3 (referenced bundle already contains a bundle child). Tier-2 cap is the architectural ceiling.
- _expand_bundle_children runs on the GET response: every bundle-typed child gets the referenced bundle's CURRENT children attached inline under the same 'children' key, plus _resolved_from / _missing_ref markers. The SPA pre-flatten path (next commit) reads these inline children verbatim — same shape as a snapshot model would have produced, just always fresh.
Headline benefit: an inner-bundle edit shows up in every parent's GET without any propagation pass. No _propagate_bundle_to_bundles, no recursive walk through children blobs. The reference is the source of truth.
11 new tests cover storage stripping, GET expansion, live propagation through reads, tier-2 rejection, missing-ref handling, and mixed leaf/ref children.
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.
Lands the broader save-to-library rethink across the server + extension
surfaces. Closes Bugs 1-5 from the 2026-05-19 live QA report; Bug 6
(payload validation) was already plugged by the previous commit.
API contract (Phase B):
- PUT /wp/api/modules/{id} now accepts {payload, name, description,
tags, propagate_to_bundles} and persists all of them in one
transaction. Returns bundles_updated[] so the caller can surface
"this save touched N bundles" feedback.
- PUT /wp/api/modules/{id}/payload kept as a backwards-compat alias
that mirrors the same body shape (payload + meta + propagate). The
older route silently dropped meta; now it routes through the same
helpers as the canonical PUT.
- Bundle propagation: when a module write happens, every bundle whose
children[].id matches the updated module gets its frozen child
snapshot rewritten in lockstep (payload + meta) and its
payload_hash recomputed. Atomic via shared db_session.
- GET /wp/api/bundles?contains_module=<id> filters the list to bundles
that reference that module — drives the modal's "this save affects
N bundles" preview.
- 7 new pytest cases in test_save_to_library_unified.py covering meta
persistence on both PUT routes, opt-in/opt-out propagation, and the
bundles filter.
SPA (Phase C):
- New PushToLibraryModal.vue. Owns editable name/description/tags +
payload preview + bundles-affected list + propagate checkbox. Two
buttons: Save as new entry (always available — POST /modules) and
Update existing (greyed when payload_hash is empty). Replaces the
five per-kind onXxxSaveToLibraryClick handlers + their five copies
of the same PUT body that lived in ModuleEditModal.
- ContextWidget right-click menu entry becomes Push to library and
routes to the same modal. Predicate relaxed from
isMissingFromLibrary(m) to !!m.payload, so inline-created modules
(no payload_hash) can finally reach the library — they get the
fork-only path in the modal.
- The five instance modals canSaveToLibrary computeds drop the
isLibraryTracked + isModified gate. Button is visible whenever
there is a payload; the modal decides what to do with it. Tests
updated to match the new contract.
- 8 new vitest cases in PushToLibraryModal.test.ts covering field
seeding, button disabling, PUT/POST request shape, bundle list
rendering, and the propagate opt-out round-trip.
- Legacy saveToLibrary(m) function in ContextWidget is gone (no
callers since the menu predicate change). Its bypass POST to
import-from-workflow lost the meta-editing path the new modal
exposes, so deletion is net positive.
Size note: total extension bundle is now 306254 / 307200 bytes
gzipped — 946 bytes of headroom on the size gate. Future feature
work touching this surface needs to factor in budget.
Live QA pending: extension code needs ComfyUI restart to reload.
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.
Pipelines never paid off as a separate module kind, so peel out:
- Engine: drop pipeline_handler, remove "pipeline" from valid module
types in repositories + schema parity.
- Manager: delete Pipelines/PipelineEditor views, PipelineSteps +
PipelineStepPicker components and their tests. Router, sidebar,
topbar, dashboard, TestRunner, ImportExport, resolver and
bundleSelection repointed off the removed routes/types.
- wp_api: drop pipeline branch from modules.py + import_export.py
(and its test).
In its place, add a unified library view:
- src/manager/views/AllItems.vue — modules + bundles in one filter
panel with kind multi-select (OR across the 6 kinds). Reachable
via the Dashboard "View all" link.
- ModuleListView grew the slots/props AllItems needs; BundleAddChild
+ Dashboard + ImportExport updated alongside.
Misc polish along the way: kind-icons / theme tokens / display-prefs
touch-ups, ContextWidget + DebugViewer + AssemblerHelper small fixes,
MockModuleCard + ModulePickerModal tweaks, _shared widget cleanup.
Module-level drift detection already polls module library hashes and
drives the per-card drift dot, but bundles had no equivalent: a
BundleHeader carried zero drift signal even when its children had
fallen out of sync OR the library entry had been edited since insert.
The user can already act on either (Reset to library / Refresh
drifted live in the bundle ctxmenu), but they had no way to spot
when action was warranted.
Two drift sources, surfaced together:
- Per-child drift — sum `isDrifted` over the bundle's child range.
Drives the existing `${n} drifted` suffix on the bundle subtitle.
Reused the already-computed per-module `isDrifted` so no extra
fetches.
- Library-side drift — compare `BundleInstance.inserted_at_hash`
against the library's current `payload_hash`. A library entry can
change without any local child changing (e.g. the library author
reordered children, swapped a child the user detached locally,
or just bumped the hash via a meta-only edit). Needed a new
source of truth: `/wp/api/bundles/hashes` (new endpoint, same
shape as `/wp/api/modules/hashes`), polled by an extended
`drift-store` exporting a parallel `bundleHashes` ref.
BundleHeader gains a small amber dot next to the bundle name when
either drift kind is active. Tooltip shapes itself to the actual
combination ("library updated + N children drifted", "library
updated", or "N children drifted") and points at the bundle
ctxmenu for the reset actions. Subtle pulse on first appear so a
mid-session library edit catches the eye.
`drift-store.fetchHashes` now fans out to both endpoints in
parallel; each call has its own try/catch so a 500 on one doesn't
blank out the other's state. Test updated to expect the new 2-fetch
fan-out shape on `forceRefresh`.
adds wp_api/library_save.py with a single route handler that validates the
incoming payload via the module type's registered handler, persists it through
ModuleRepository.update, and returns {"ok": true, "new_hash": "<sha256>"}.
covered by 5 new async tests in tests/wp_api/test_library_save_endpoint.py.
also fixes pre-existing ruff E501 violations in test_derivation_handler.py
and test_instance_schema_parity.py that were blocking the pre-commit hook.
Two bugs surfaced by manual QA after picker unblock:
1. Every freshly-picked non-wildcard module rendered a red "missing"
dot on the card. Root cause: GET /wp/api/modules/hashes hard-coded
`type="wildcard"` (5.5.5 spec §4.2 reasoned "catalog only contains
wildcards"), so when the in-graph WP_Context now embeds a combine /
derivation / constraint / fixed_values, the resulting payload_hash
never appeared in the libraryHashes map. The frontend predicate
`isMissingFromLibrary` then flagged every non-wildcard as missing.
Fix: drop the `type=` filter so the endpoint returns hashes for
every kind. Cost: ~50% larger payload for typical libraries; still
sub-10 KB for the largest libraries we've seen.
2. Picker enabled pipeline kind, contradicting the user-stated scope
that pipelines stay deferred until the modal grows a pipeline
preview. Fix: introduce a `HIDDEN_KINDS` set + `visibleModules`
computed that filters pipeline rows out of the picker entirely.
Pipeline tab dropped from the kind-tab strip. The defensive guard
in `isPickable` reads from the same set so any future programmatic
pick path inherits the gate.
Tests:
- Updated picker test to reflect 5 visible rows (pipeline filtered)
+ asserts the "Pipelines" tab and the literal pipeline row text are
absent from the rendered picker.
- Renamed + updated the backend hashes test to lock the new
every-kind contract; combine row now expected in the response map.
485 vitest + pytest green.
Dashboard count cards displayed "1" for every kind regardless of how
many rows the user actually had in the library. Root cause: the list
endpoint returned `total = len(items)` AFTER pagination, so any client
that passed `limit=1` for cheap polling (Dashboard does exactly this)
got `total=1` no matter what.
Adds `ModuleRepository.count(...)` that runs the same WHERE-clause
filters as `list()` but issues `SELECT COUNT(*)` with no LIMIT/OFFSET,
and reroutes both calls through a shared `_build_filter_clause` helper
so filter shape can never drift between the two paths.
Pinned by `test_list_total_ignores_limit` — seeds 3 wildcards, queries
`?type=wildcard&limit=1`, asserts items has 1 row but total is 3.
Wire walk_transitive_refs into _build_ctx_with_catalog so the SPA Test
Runner populates __wp_catalog__ from the live DB before resolution. Also
fixes _resolve_ref to support SnapshotEntry payload.options shape alongside
the legacy flat options shape, and updates db_session to accept an injected
connection from request.app["wp_db"] for test isolation.