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.
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.
Wildcard + fixed_values surfaces are binding PRODUCERS, not consumers
— they don't read upstream \$var. Combine + derivation + assembler do
(template interpolation is their reason to exist).
Adds VarOutOfSurfaceError to engine.syntax.types + extends SurfaceKind
literal with "fixed_values". _resolve_var checks surface membership
in _VAR_SURFACES_ALLOWED frozenset; out-of-surface refs warn + render
literal \$name in lenient mode (existing default), raise in strict
mode (used by tests + dev mode).
Lenient strategy means workflows saved before this cycle keep
resolving — the warning surfaces in toast / conflict scanner without
crashing the queue.
Test fixture adjustments document the new contract:
- tests/syntax/test_resolve.py default surface flips wildcard -> combine
(these tests cover VAR resolution mechanics, not surface gating)
- tests/syntax/test_determinism.py corpus single_var + var_in_text
cases use surface=combine
- tests/engine/modules/test_wildcard_handler.py:
* test_resolve_var_substitution_in_option renamed to
test_resolve_var_in_option_renders_literal_warns + asserts new
lenient path
* test_resolve_pinned_mode_resolves_inline_syntax swaps \$color for
a {red|red} alternation that DOES resolve on wildcard surface
Surface gating tests live in tests/engine/syntax/test_resolve_var_surface.py
covering all 5 surfaces + lenient vs strict modes + internal-key skip.
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.
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.
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.
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.
Combine semantics: read upstream bindings + emit one composed
output_var. When a template references a var nothing upstream has
bound (e.g. user mistypes "$colur" or removes a wildcard), the
resolver silently collapsed the token to empty string and the
combined output came out wrong with no signal — user had to
reverse-engineer "why is half my prompt blank?"
Add a `unknown_var` warning at the lone _resolve_var call site:
type "unknown_var", severity "warn", detail carries the var name +
the surface. Surface-agnostic — wildcards, derivations, and
constraints get the same signal when they reference a var that
isn't in ctx. Internal-key lookups (`__wp_*` engine bookkeeping)
are still skipped silently — those legitimately won't be in vars.
The TODO at the original site (before this commit) acknowledged
the silent-empty behaviour was a gap pending user feedback. User
asked for it explicitly today.
Tests:
- combine handler now asserts the warn fires + carries the right
detail when a template var is missing
- sanity test that bound vars don't trigger the warning
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.