Wildcard steps list their options with weights and why each was cut, the pool chain, locked seed, separator and matched value; fixed values, combines and node templates show what they read; constraint steps link the steps they re-weighted; Model Info, Toggles, Injector and Context Loop rows carry their own facts.
Chat round (explain on selection, page starters, reactions, streamed notes, thinking), negatives on node fixed values and combines, Pack builder quality fixes from 31 real-model eval rounds, and small-model support (scoped tools, checked tool arguments on local servers, two-phase steps, fixed prefix shown in Test connection).
Wildcard options, fixed values and combines can carry a `negative`, and a
derivation action gains the `negative` ("Add to negative") mode. The
engine files negatives per variable in `__wp_negatives__`, carries them
across nodes, and the Assembler gets a `negative` output built from the
variables its template rendered, filled into a `negative_template` at
`$negatives`. Negatives resolve on their own seed stream and refs inside
them resolve quietly, so picks never shift. Injector rows can set a
negative, the Prompt Cleaner cleans an optional negative with its own
rule column, and the preview endpoint returns the negatives table.
Schema v8 stamps payloads that use a negative.
seed_scope=hold previously swapped in a constant seed and re-picked each
iteration. That failed two ways: (1) a held wildcard whose pool is reshaped by
a constraint (varying source) picks a different option from the same seed; and
(2) under Override seed, the 'hold' seed equalled the per-iteration derived[k]
(effective_chain_seed returns an override verbatim, ignoring loop_index=0), so
hold was a no-op. Nested @{} refs in a held value also re-rolled.
Now hold freezes the frame-0 RESOLVED value. context_node computes a constant
hold seed from the loop's derived-series head; pipeline runs a throwaway base
pass at loop_index=0 when a loop drives + any module holds, and the
wildcard/combine/fixed-values handlers copy their resolved value from that base
context verbatim — skipping the pick AND resolve_text, so a constrained or
@{}-bearing held wildcard stays frozen (green jeans stays green jeans).
Tests: constrained held wildcard + nested-ref held wildcard each stay constant
across iterations while their vary counterparts move.
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.
Combine handler now honors two new instance fields the v2 modal will
write:
- template_override (string | None) — full per-instance template
replacement; falls back to payload.template when null. Same precedence
pattern wildcard's option_weights and fixed_values' values_overrides
use.
- locked_seed (int | None) — pins {a|b|c} resolution so the same seed
reproduces the same roll. RNG derived via shared
engine.modules._seed.derive_module_rng using output_var as the
per-module key. Without locked_seed, falls back to the chain seed
(existing behavior).
Both paths feed the SAME derive_module_rng(seed, output_var), so locking
with locked_seed = chain_seed reproduces the unlocked pick exactly —
that's the contract the lock UX relies on.
5 new tests: template_override precedence, null collapse, locked_seed
determinism, statistical independence across seeds, unlocked uses
chain seed.
CombineHandler now calls resolve_text(template, build_resolve_ctx(ctx, surface="combine"))
replacing the manual $TOKEN_RE substitution loop. Unknown $vars emit "" (resolve_text
contract) rather than passing through unchanged; @{uuid} refs in combine surface are
lenient — emit empty string + ref_out_of_surface warning.
DerivationHandler wraps action.value through resolve_text with surface="derivation"
before applying the replace/append/prepend mode, enabling $var interpolation and
{a|b|c} picks inside action values.
ConstraintHandler is a pure metadata pass-through that records reweighting matrices
for future WildcardHandler consumption. It never processes user-authored text strings,
so no migration is required (no-op verdict).
Tests updated: combine tests drop ctx=None path and the pass-through unknown-var
assertion (both incompatible with resolve_text contract); derivation tests add
__wp_rng__ + __wp_warnings__ to all ctx fixtures; two new combine surface tests
added to tests/test_handlers.py per task spec.