Conditions store condition.var as raw text rather than tokens, so this path
parses the accessor itself via parse_var_reference. _ctx_set is left on
split_var_accessor: an action writing to $outfit.SHOES should still target
the base var rather than pretend to write an axis.
_ctx_has checks the axes bucket rather than ctx, since $outfit.SHOES can
exist while outfit is unbound and vice versa.
Action values needed no work - they already route through resolve_text.
Hold was replaying the derivation's frame-0 OUTPUT, which froze WHICH branch
fired -- wrong. A derivation is a transform: the condition should still pick
the branch from the current inputs each iteration; only the random roll
inside a branch should freeze. Now hold resolves with the constant hold seed
keyed PER BRANCH (rule:branch), so each branch's inline {a|b} / @{} value is
frozen to its frame-0 roll independent of which other branches fired -- but a
varying input still switches IF->ELIF and uses THAT branch's frozen value
(== what that branch would have rolled at frame 0).
Drops the now-unused __wp_module_outputs__ capture from pipeline.run (the
freeze-output replay was its only consumer). wildcard / combine /
fixed_values still freeze the whole resolved VALUE via __wp_hold_base_ctx__,
because a constraint can reshape their pool so a frozen seed isn't enough --
a derivation branch's pool is fixed, so per-branch seed freeze suffices.
A derivation is a transform that writes a DYNAMIC set of vars (whichever
rules fire), so it can't reuse the single-binding hold path. The pipeline
now captures each module's frame-0 output dict keyed by per-instance uid
during the hold base pass (__wp_module_outputs__); the derivation handler
replays its captured dict verbatim on hold. The captured values are
post-resolve, so inline {a|b} picks AND nested @{} refs freeze too -- and
the output stays frozen even when the derivation's INPUT varies (the same
value-freeze trade-off a constrained wildcard makes).
Adds derivation to HOLD_KINDS + a "Hold across run" toggle on the
derivation RuntimeSection. The row right-click Hold item picks it up for
free via HOLD_KINDS.
Extract branch_carrier_key() (engine) and branchKey() (TS) so the derivation branch-key format (rule_id:bi / rule_id:else) has a single source per language; route all call sites through them.
Add branch_key_cases to tests/fixtures/constraint-corpus.json; the Py runner (test_constraint_corpus.py) and TS runner (constraint-pairs.test.ts) each assert their production helper against the shared fixture key, so the two formats cannot drift independently.
Resolved-output parity is engine-only (TS never resolves derivation refs); A1 already locks it.
_apply_action now accepts carrier_key and stamps (module_uid, branch_key)
on the resolve context before calling resolve_text, enabling SP3 pick-reach
constraints to target wildcards nested inside a specific derivation branch.
Branch call site passes f"{rule_id}:{bi}"; else call site passes
f"{rule_id}:else".
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.
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.