Replace the constraint one-shot/consumed-set model with a reach selector:
each constraint's target_select (default {mode:"all"}) decides which firing
target instances it covers, and every covering constraint is applied +
combined sequentially. A per-constraint hit counter (__wp_constraint_hits__)
replaces the consumed-set; reach is downstream-relative (a constraint is only
in ctx after its module ran).
- apply_constraints_for_target rewritten: hits/firing_uid/carrier_ctx params,
_occurrence_matches matcher (carrier_ctx None from callers — nested-pick
threading is a later task). consumed= kept accepted+ignored for back-compat.
- claim_carrier_constraints deleted (the positional spill failsafe is obsolete
without consumption); its import + both wildcard_handler call sites + the
_claim_carried helper removed.
- resolve.py nested path shares the ctx hit counter (get_constraint_hits) so
first/next coverage spans direct + nested encounters; carrier_ctx=None.
- pipeline.py finalisation now hits-based: constraint_never_applied when a
selector covered zero, new constraint_partial_reach when next N / pick
matched fewer than asked.
- cross-node: __wp_constraint_hits__ replaces __wp_consumed_constraints__ in
_CROSS_NODE_INTERNAL_KEYS; build_payload dedup also collapses partial_reach.
Existing one-shot tests adapted to the new semantics:
- test_constraint_first_instance: the implicit "fires once" tests re-expressed
via explicit target_select={mode:"first"}; added an all-mode-default test and
a partial_reach finalisation test. The pre-seeded-consumed no-op test became a
first-mode "counter already past 1" test (consumed-set concept gone).
- test_user_chain_simulation: consumed assertions → hit-count assertions; the
carrier-claim tests (no-spill, null-pick claim, source-not-picked, sibling
one-shots) rewritten to the new model — a carrier that doesn't resolve the ref
no longer "claims" anything, so the constraint applies on the next actual
resolution (or legitimately never fires → never_applied).
- test_module_interactions: "only first fires" composition test replaced with a
two-all-constraints-compose-on-one-target test (both excludes zero the pool →
excludes-all fallback), plus a single-all-constraint survivor test.
Exposes the pick_independent instance field (engine resolution landed in 33b3600): instance schema + snapshot round-trip, TS instance type + _shell INSTANCE_FIELDS_PER_KIND registry (so reset-overrides + modified-state + the TS/Py parity gate all include it) + preview cache-key, and the canvas PoolSection 'Allow repeats' toggle (shown only while multi-pick is active; off = unique). Instance-only (per-node, canvas runtime), additive + tolerant — no community catalog change.
The pool multi-pick (PICK min-max) now honors instance.pick_independent: when true it draws WITH replacement (repeats allowed) and the count is NOT clamped to the pool size, mirroring the inline ~ flag. Default (unique, pick_k_unique) path is byte-unchanged, so existing locked seeds reproduce. Foundation for the 'Allow repeats' pool toggle; schema (instance + library default) + TS parity + UI follow.
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.
The consumed-set keyed on __constraint_module_id__ = the LIBRARY uuid,
so two instances of the same constraint library entry collapsed into a
single one-shot. CLAUDE.md is explicit: 'each constraint module is a
one-shot ... author multiple constraint modules to affect multiple
target instances.' Two authored modules must be independent.
The carrier-claim failsafe made the collision visible: a carrier
(backdrop) claiming the shared cid spent the WHOLE hair_x_mood family,
so a downstream direct mood instance — which the pair badge assigns to
hair_x_mood #2 — then rolled unconstrained.
Changes:
- pipeline.py stashes __wp_current_module_uid__ (the per-instance _uid)
alongside the existing __wp_current_module_id__ (library uuid), and
pops both after each module. The wildcard handler still matches the
library id against constraint targets; only the constraint handler's
consume key switches.
- constraint_handler keys __constraint_module_id__ on the uid, falling
back to the library id when _uid is absent (legacy / hand-built test
modules — preserves their behaviour).
- claim_carrier_constraints now claims at most ONE constraint per
carried target (first unconsumed), so a multi-ref carrier can't
swallow an entire same-target family — it consumes one per target,
exactly like apply_constraints_for_target fires one per call.
Regression test reproduces the user's exact shape (two hair_x_mood with
the same library id + distinct _uid): backdrop claims instance A,
top-level mood claims instance B, mood ends up constrained. All 1027
pytest + 2381 vitest pass.
Two follow-ups from the user's screenshot showing a misleading
never_applied warning for @color after backdrop rolled its null option:
- wildcard_handler skipped the carrier-claim on the empty-value early
return — claim_carrier_constraints sat only after resolve_text, but a
null/empty pick returns before reaching it. So a backdrop that rolled
'null' never claimed the constraints it carries, letting @color
either spill or warn. Extracted the claim into a local _claim_carried
closure and call it on BOTH the empty-value path AND after
resolve_text. A carrier that rolls ANY option (including null) now
claims its carried constraints, matching the user's no-spill choice.
(Explains the color-vs-mood asymmetry too: mood had a top-level
instance downstream that caught hair_x_mood, color didn't — so only
color surfaced the bogus warning.)
- the 'present' never_applied message wrongly prescribed 'move the
constraint above the target's first appearance' and blamed
registration order. That advice is wrong when the target's carrier is
ALSO the constraint's source (the source must stay upstream). Reworded
to state the observation without the bad fix-it, and dropped the
ordering-only framing.
Regression test test_carrier_claims_on_null_empty_pick locks in the
null-pick claim across 5 seeds. All 1026 pytest + 2381 vitest pass.
Two changes driven by the user's real chain (shirt option carries
@{color}; backdrop carries @{color}+@{mood}; constraints sit between
them):
Carrier-claim failsafe (no spill past a carrier)
A constraint is now positionally claimed by the FIRST downstream
wildcard that could host its target — a direct instance OR a
nested-ref carrier — matching what the pair badge already shows.
After a wildcard resolves, claim_carrier_constraints consumes any
unconsumed constraint whose target the wildcard carries (an
@{target} ref in ANY of its options), provided the source is
already picked. So when backdrop rolls a no-ref option, it still
claims the constraint instead of letting it spill onto a later
top-level target instance. No-op when the chosen option DID resolve
the ref (nested path already consumed it) or the source isn't
picked yet.
Cause-aware never_applied warning
The end-of-run warning now classifies WHY a constraint didn't fire:
_target_present_in_chain scans the module list + catalog for a
direct instance or a carrier of the target. Present → 'target
exists in the chain but wasn't claimed (rolled before this
constraint registered, or in a separate branch)'. Absent → 'no
instance or nested-ref carrier found (check the target uuid)'.
detail.target_present is stamped so the DebugViewer can later pick
a distinct icon without re-parsing the message.
Net for the user: the failsafe means a rolling carrier always claims
its constraints, so the confusing per-seed never_applied warnings for
backdrop disappear entirely; the only warnings left are genuine
absent-target / ordering cases, each with actionable wording.
5 regression tests added (carrier claim + no spill across 20 seeds,
source-not-ready no-op, present-vs-absent message). All 1029 pytest
+ 2381 vitest pass.
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.
- Constraint first-instance one-shot: each constraint claims first
downstream target wildcard instance then is consumed. Engine tracks
via ctx __wp_consumed_constraints__ across pipeline + nested ref
resolves. Emits constraint_never_applied (info) for unused at end.
- Null wildcard option: is_null flag opts into no-output slot that
survives category filter + matrix constraint. Reserved sub_category
name guard forbids null as real sub-cat.
- Inverted nested-ref null filter (2026-05-25): null keyword in filter
list now EXCLUDES wildcard null option. Default keeps null in pool
alongside listed sub-cats.
Three improvements aimed at making constraints actually useful in
composed pipelines, with the conflict scanner keeping up.
## Phase A — nested constraints (engine)
Pre-2026-05 constraints only fired when the target wildcard ran as a
top-level chain module. Wildcards reached through `@{uuid}` refs from
inside another wildcard's option value silently bypassed every rule.
Composition (outfit-set wildcards, palette reuse) was a constraint
blind spot.
- New `engine/modules/_constraints.py` holds the shared application
logic (loop constraints by target uuid, look up source pick, apply
matrix/exception cells, emit unknown_constraint_source +
constraint_excludes_all_options warnings)
- `wildcard_handler.resolve` now delegates to the helper instead of
inlining the loop
- `engine/syntax/resolve._resolve_ref` calls the same helper after
fetching the catalog wildcard but before rolling — nested target
wildcards now get constrained identically to chain-level ones
- `ResolveContext` exposes `get_constraints()` + `get_picks()` so the
resolver has the back-channel it needs without depending on the
full pipeline ctx dict
## Phase B — `@{uuid:subcat[,subcat]}` filter
New optional ref syntax. `@{color:warm}` resolves the catalog wildcard
`color` but restricts its option pool to entries whose `sub_category`
is in the filter list. Same semantics as chain-level
`instance.category_filter`, but scoped per call site so one library
wildcard stays reusable.
- Python tokenizer regex extended to capture optional `:subcat,subcat`
group; `_resolve_ref` filters options pre-pick + pre-constraint
- TS `richTokenize.ts` mirrors the grammar so previews + RichTextInput
recognise the new form
- `extension/graph.ts:WC_REF_RE` + `preview-tokens.ts:REF_RE` updated
to skip the filter when extracting uuids (uuid is still group 1)
- Empty pool after filter → empty string output + new
`ref_subcategory_empty_pool` warning so the user sees an
unsatisfiable filter instead of falling back silently
## Phase C — scanner reach-set + classification refinement
Constraint scanner pre-fix:
- flagged `constraint_target_in_upstream` whenever target had ANY
upstream instance, even if a downstream / local-after / nested-after
instance was also present
- flagged `constraint_target_missing` for targets reachable only
through nested `@{}` refs
Both false-positives. Post-fix scanner computes
`localNestedReachAfter[i]` — transitive `@{}` walk through the local
catalog starting from modules at positions ≥ i — and uses it to:
- skip `target_in_upstream` when a bindable instance exists elsewhere
(downstream, local-after-self, nested-after)
- skip `target_missing` when target is reachable via nested ref from
any later chain wildcard
- still flag both when no bindable instance exists (sole-upstream,
truly-missing both remain caught)
## Tests
- 8 new Python tests under `TestNestedConstraints` +
`TestNestedSubcategoryFilter` (constraint applies through nested,
excludes-all warning, source-not-yet-picked warning, subcat narrows,
multi-subcat union, empty pool warning, empty filter no-op,
subcat+constraint composition)
- 7 new TS tests under `scanConflicts — constraint nested-reach
classification` (nested reachable doesn't flag missing, transitive
deep reach, upstream+downstream silent, upstream+local-after silent,
sole-upstream still flags, nested-from-before-constraint still
flags, nested with subcat filter still reaches)
## Numbers
- 696 pytest pass (+8)
- 1672 vitest pass (+7)
- ruff clean
- bundle 306899/307200 (301 bytes headroom)
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.
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.
The previous commit let users cycle empty matrix cells in the modal but
the engine only iterated `payload.matrix` — overrides for keys absent
from the library were silently ignored. Cycling produced dead UI: state
saved, engine ignored, runtime identical.
Engine extension: after the library-merge pass, do a second pass over
`cell_mode_overrides` ∪ `cell_factor_overrides` minus `covered_keys`.
Each leftover key becomes a new matrix entry with implicit baseline
mode "allow" + factor 1.0; whichever side(s) the user set populate the
rest. Disabled set still wins — entries in `disabled_matrix_cells` skip
both passes.
Added `decode_key` helper alongside `encode_key` for the second-pass
src/tgt extraction. Round-trip + malformed-input rejection covered by
new key tests. Malformed override keys in the resolve path are skipped
silently rather than crashing the run (defensive — instance JSON could
have rotted across schema migrations).
Updated the existing "unknown key silently ignored" test to assert the
new behavior (entry created from override). Added focused tests for:
factor-only override defaults to allow mode, disabled set still skips
empty-cell overrides, malformed key in override map doesn't crash.
Each emitted value now passes through resolve_text with
surface="fixed_values" so {a|b|c} alternations + {N\$\$sep\$\$...} repeats
+ escapes resolve. \$var reads + @{uuid} refs are gated off (lenient:
warn + render literal) — fixed_values is a binding PRODUCER, not a
consumer.
Seed lock: instance.locked_seed pins {a|b|c} resolution per instance
via the shared engine.modules._seed.derive_module_rng helper, keyed by
ctx["__wp_module_id__"] when present (else "fixed_values" literal).
Backward compat: ctx=None / minimal-dict callers skip the
syntax-resolution path via _ctx_supports_resolve guard.
5 new tests: alternation per value, locked_seed determinism, escape
renders literal \$, \$var warns + literal, values_overrides precedence.
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.
Pure refactor — wildcard_handler.py imports from new shared module
instead of defining inline. Combine + fixed_values cycles will reuse
the same helper for their seed-lock support.
Identical SHA-256 derivation, same call sites. All 210 engine tests
pass after the lift.
Two CRITICAL/IMPORTANT findings from final review:
1. DisabledExceptionsSection + prune.ts read only Tier 2 `source_value`/
`target_value` while engine accepts both Tier 2 and legacy
`source`/`target` (constraint_handler.py:215). UI on a legacy-
shaped payload would emit encodeKey([undefined,undefined]) and
silently drop saved disable selections + falsely flag valid keys
as stale on refresh. Mirror the engine fallback chain in TS.
2. _fresh_instance() defaulted variable_binding to '' and internal to
False. The frontend modified-state predicate is value != null, so
any non-None sentinel falsely lights up the orange dot, forces the
modal to default to Instance tab, and tags the section as modified
on a pristine snapshot. Engine readers already coerce None at the
read site (wildcard_handler.py:216 falsy-or fallback, pipeline.py:160
bool() coercion), so we keep a single contract: None everywhere =
no override.
Also: prune.ts now uses encodeKey() helper instead of raw
JSON.stringify for consistency with the cross-language contract.
Code review (silent-failure-hunter + feature-dev:code-reviewer) flagged seven paths where the engine or scanner silently swallowed problems instead of warning. Fixed the high-confidence ones:
1. `engine/modules/wildcard_handler.py:_apply_constraint_to_options` — `mode: "allow"` with `factor != 1` silently ignored the factor (SPA editors expose the factor field for every mode, so users naturally try `allow + factor=0.5` expecting weight reduction). Added a `constraint_factor_ignored_on_allow` warning surfaced via the existing `__wp_warnings__` channel. Defensive `unknown_constraint_mode` path also added — currently dead code (validator rejects typo'd modes upfront) but cheap belt-and-suspenders for any future path that bypasses validate.
2. Constraint excludes-all detection — when the matrix excluded every option in the target's pool the post-application total weight was 0 and `_pick_weighted` fell back silently to `options[0]`. The user got the same first option forever with no signal that their constraint over-narrowed. Added a `constraint_excludes_all_options` warning fired when constraints applied AND total weight collapsed to 0.
3. `engine/modules/constraint_handler.py:_ctx_set_constraint` — both the setter API and dict-fallback paths swallowed `Exception` silently, leaving the constraint un-registered with zero signal. Added a `constraint_register_failed` error-severity warning so a fundamental wiring break shows up in WP_Debug instead of being mysterious.
4. `engine/modules/fixed_values_handler.py` — non-list `instance.values_overrides` (string/dict from old SPA save or hand-edited JSON) silently fell back to library payload, dropping the user's edits without explanation. Added a `fixed_values_overrides_malformed` warning naming the wrong type so the fallback is debuggable.
5. `src/extension/preview-resolver.ts` — failure ledger was a `Set<string>` that made every failure permanent for the session. A single page-load 5xx flake left a uuid stuck rendering as `$ae07018b` until full reload. Replaced with `Map<string, {at, permanent}>` plus a 30s retry TTL: 404 stays permanent (server confirms the uuid doesn't exist), every other status retries after the TTL. Network/JSON errors are also retryable now and log once via the same `window.__wp_walker_log__` flag the walker uses.
6. `src/extension/conflicts.ts` — `localWildcardIndex` only tracked ENABLED wildcards. A constraint pointing at a disabled local wildcard would falsely flag as "constraint_source_missing" / "constraint_target_missing" because the disabled module was invisible to the index. Track both enabled and disabled — disabled-state is a separate concern from "does the uuid live in this node."
Tests: 3 new constraint-integration cases (factor-ignored-on-allow, excludes-all, plus the existing positive-path checks unchanged), 2 new fixed_values cases (malformed warning fires, no-warning when overrides absent).
468 pytest, 523 vitest, typecheck clean, entry 2.71 KB / total 186 KB gzipped.
Audit found other items still outstanding (sameAsLibrary index-strict compare, multi-constraint composition surprise, shadows+duplicate dual-emit policy, ctx as growing god-object) — deferring those as design questions rather than reflexive code changes.
QA reported two ordering gaps in the constraint module — the second one surfaced as a runtime ValueError that killed the whole constraint at validate time:
1. Static ordering: a constraint card stayed silent when its source/target wildcards were missing or in the wrong execution order. Runtime emitted `unknown_constraint_source` once it hit a missing source, but the user only saw it in WP_Debug after queuing, and the more nuanced cases (target before constraint, target in upstream Context) had no signal at all.
2. Strict factor > 0: SPA-saved payloads occasionally surface zero-factor cells (the editor exposes them as a legitimate "exclude this pair" form). The handler rejected them with "factor must be a positive number," which made the entire constraint fail validation and skip its bucket registration.
Static ordering — four new conflict types in `extension/conflicts.ts`:
- `constraint_source_after_self` — source in same node at index >= constraint
- `constraint_source_missing` — source uuid not in this node nor in upstream
- `constraint_target_before_self` — target in same node at index <= constraint
- `constraint_target_in_upstream` — target lives in an upstream Context (already picked)
`scanConflicts` accepts a new third arg `upstreamWildcardUuids: string[]` so it can tell "source upstream = good" apart from "source nowhere = bad." The check fires alongside the existing missing/dup/shadow logic; multiple problems on one constraint surface as separate dots in the tooltip. Target NOT in same node and NOT in upstream is intentionally NOT flagged — could still be a valid downstream Context, and the static scanner has no downstream walker to differentiate from a typo. Runtime still catches that case.
Walker plumbing — `extension/graph.ts` adds `collectUpstreamWildcardUuids(rootGraph, node)` returning the set of every wildcard module's id reachable upstream (skipping muted/bypassed nodes, mirroring the existing var walker). Wired through `widgets/context.ts` → `ContextWidget.vue` prop → scanner.
`labelFor` extended for the four new types so card tooltip + subgraph badge agree on wording: "source after constraint", "source missing", "target before constraint", "target already picked upstream".
Validator relax — `engine/modules/constraint_handler.py:_validate_cell` and the exception-list check now allow factor=0 (effectively "exclude") and only reject negative factors. The legitimate SPA-saved zero-factor payload now passes validation; the constraint registers normally and the wildcard handler sees its bucket. Updated the matching test from "rejects zero factor" to "accepts zero factor" with an inline comment naming the regression.
Tests:
- 8 new scanner integration cases — clean chain, source-after-self, source-missing, target-before-self, target-in-upstream, target-unfindable-no-warning, disabled-skip, dual-warning composition.
- 3 new walker cases — uuid collection across upstream Contexts, mute/bypass exclusion, no-upstream-link.
- Updated constraint handler test for the new factor=0 acceptance.
Totals: 523 vitest, 464 pytest, typecheck clean, entry 2.71 KB / total 185 KB gzipped.
Wires constraint-aware option-pool adjustment into WildcardHandler. Pre-fix the constraint module was a metadata-only stub — its bucket landed in `ctx["__wp_constraints__"]` but no consumer ever read it, so configurations like "hair_style → constraint → mood" produced random mood picks regardless of which hair_style won. Now the wildcard handler reads the bucket each pick, finds constraints whose `target_wildcard_id` matches its own module id, looks up the source's pick, and applies the matrix + exception rules to its option weights before the weighted RNG fires.
Pipeline plumbing: `engine/pipeline.py` stashes the active module's id in `ctx["__wp_current_module_id__"]` for the duration of each `resolve_module` call, and clears it after the run. Handlers that need to identify themselves (constraint-aware wildcards looking up their own target_wildcard_id) read this key — it's the cheapest hand-off without changing the dispatcher signature.
Pick tracking: every successful wildcard pick (random, pinned, weighted) records `{value, sub_category, id}` in `ctx["__wp_picks__"][module_id]`. Source wildcards must therefore run before their constraint targets — pipeline order is load-bearing. When a target's source hasn't been picked yet, the wildcard skips the constraint, picks normally from its un-adjusted pool, and emits an `unknown_constraint_source` warning so the user gets a debug-viewer signal pointing at the chain-order bug.
Constraint application:
- Matrix layer: indexed by `(source.sub_category, target.sub_category)`, expresses bulk rules ("long hair allows formal outfits").
- Exception layer: indexed by the literal `(source.value, target.value)` pair, narrower-rule precedence — wins over the matrix cell when both apply.
- Modes:
- `allow` → weight unchanged
- `exclude` → weight set to 0 (option drops out)
- `boost` → weight × factor (factor > 1 typical)
- `reduce` → weight × factor (factor < 1 typical)
Multiple constraints targeting the same wildcard compose multiplicatively — they're applied in registration order, each one further adjusting the pool the previous one produced.
Tests: 7 new integration cases driving real PipelineEngine — pick tracking, matrix exclude, matrix boost (statistical convergence over 40 seeds), exception value-pair exclude, exception-overrides-matrix precedence, missing-source warning, no-constraints default path. 464 pytest + 512 vitest.
Fixed_values now mirrors the wildcard pattern: library-picked modules treat `payload.values` as the immutable library snapshot, and user edits land in `instance.values_overrides` instead of mutating the snapshot in place. Aligns the kind with how wildcards already work — "modified" becomes a real card-level signal, "reset to library" becomes a one-click affordance, and library drift detection no longer collides with local edits.
Engine (`fixed_values_handler.py`): two-tier read. Overrides win when present and non-empty; otherwise the library `payload.values` resolves as before. Defensive against malformed (non-list) overrides — defaults to library payload.
Modal save: branches on `payload_hash` presence. Library-tracked modules write entries to `instance.values_overrides`; library `payload.values` is never touched. Inline-created modules (no `payload_hash`) keep editing `payload.values` directly — original behaviour, no library anchor to preserve. Save also includes a soft-reset path: when entries exactly match library values, the override is deleted instead of stored, so the modified dot clears automatically.
Modal UI: when a library-tracked fixed_values has overrides set, a "reset" bulk-button appears next to "add". Clicking reverts entries to `payload.values` and drops the override field. The user still has to Save to commit, preserving the cancel-discards contract.
ContextWidget `isModified`: extends to fixed_values via `instance.values_overrides` non-empty check. Inline-created modules never light up — they have no library anchor to diverge from. Existing wildcard branches unchanged.
Frontend mirrors of the engine read (`extension/graph.ts:writeBindings`, `extension/conflicts.ts:writesOf`): both consult overrides first, fall back to entries + payload.values. Keeps the assembler preview and the conflict scanner consistent with the runtime resolver.
Migration: workflows saved before this change have edits stored in `payload.values` for library-tracked fixed_values. Engine still reads payload.values when no override is set, so they continue to resolve correctly. The modified dot only fires for new edits via the new modal save path — existing pre-migration content shows as "in library" until next opened + saved. Acceptable cost; the alternative would require a server round-trip to compare against live library hashes.
Tests: 6 new engine handler cases (override path, empty fallback, add/drop entries, malformed override defense), 6 new modal save/reset cases (library-tracked write to overrides, no-edit clean save, inline payload write, reset button visibility + click, both negative cases). 512/514 vitest, 457/461 pytest.
Three QA-driven fixes from the post-5.5.6 canvas pass:
1. Conflict label drift. Card tooltip said "overrides upstream / duplicate / missing variable" while the subgraph badge said "shadows / duplicate / missing" — same conflict types, different words. Hoisted a single `labelFor(type)` helper into `extension/conflicts.ts` and routed both consumers through it. Wording is now uniform: "overrides upstream / duplicate / missing".
2. Constraint metadata leak. `ConstraintHandler.resolve` wrote its bucket to `ctx["_constraints"]` (single-underscore key). `engine.context.strip_internals` only filters double-underscore (`__`) keys, so the bucket leaked to the public socket payload — the assembler's autocomplete listed `$_constraints` as a pickable variable even though no module had bound it. Renamed the key to `__wp_constraints__` so the standard internals-strip drops it. Added a regression test that runs the handler then asserts strip_internals removes it.
3. Modal entry-row leave animation never fired. The original CSS had only `wp-medit-list-enter-*` classes; deleting an entry just blanked the row instantly because `*-leave-active` / `*-leave-to` were undefined. Added a 120ms fade + 8px slide on leave with `position: absolute` so the row exits without blocking the FLIP-move on surviving siblings. Matches the new card-style entry container — vanish-without-feedback felt broken in the redesigned look.
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.