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.
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.
- 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.