Goal
One composable permanent filter answers "which permanents?" for every non-targeting
selector, so a new mass effect, static ability, or count is a JSON change instead of an enum
variant plus a match arm in each consumer.
Context and evidence
Parent: #819, §2.1. This is the first of two halves; #825 does the targeting half, which is
the one that crosses the protocol seam.
Four enums answer the same question — which objects? — and none of them composes or shares a
matcher:
| Enum |
Where |
Used by |
MassAffects |
ability/selector.rs:617 |
PumpAll, GrantKeywordAll, RestrictAll |
StaticAffects |
ability/static_ability.rs:14 |
every continuous ability |
DestroyAffects |
ability/selector.rs:273 |
DestroyAll only — two variants, one card |
PermanentCount |
ability/selector.rs:355 |
conditions and counted amounts |
PermanentCount is already the shape the other three want:
pub struct PermanentCount {
scope: CountScope, card_type: Option<CardType>, subtype: Option<String>,
color: Option<Color>, min_power: Option<i32>, with_counter: Option<CounterKind>,
nontoken: bool, distinct_names: bool,
}
The codebase already argues for exactly this. ObservedActivation's doc comment:
A small product of two independent filters rather than a closed list of named classes, for
the reason PermanentCount is one: the two questions a card asks about an activation …
vary independently, and a variant per pairing would grow the vocabulary once per card.
MassAffects was not given that treatment, and its own doc records the consequence — "It
grows by adding variants (attacking creatures, tapped creatures, …) as cards need them."
Counting the affects.scope values actually used across the 299 catalog files: of the mass
scopes in use, each_creature_token, creatures_without_flying,
creatures_and_planeswalkers_your_opponents_control, attacking_creatures,
creatures_that_player_controls, each_creature, each_artifact_or_enchantment,
permanents_your_opponents_control, and attached_to are each used by exactly one card.
DestroyAffects exists only because MassAffects is creature-only — its doc says so: "a
non-creature scope there would make 'artifacts you control get +1/+1' an authorable sentence
that means nothing." A shared filter with a card-type field makes that a validator question
rather than a reason for a fourth enum, and deletes DestroyAffects outright.
exclusions.json currently names two limits that are limits of this shape rather than of the
rules:
- "Static abilities that affect a class of the source's controller's own noncreature
permanents"
- "Selectors that filter by toughness"
Both are one field on a shared filter.
Scope
- One
PermanentFilter covering the axes already modelled across the four enums: controller
relation, card type, subtype, colour, a power bound, a counter, token-ness, and the
except_this that only a continuous ability has a "this" for.
MassAffects, StaticAffects, and DestroyAffects re-expressed in terms of it;
DestroyAffects deleted.
PermanentCount reconciled with it — one struct if the axes line up, or clearly one wrapping
the other with the difference stated.
- One
matches predicate, so a class means the same thing to a pump, a static ability, a
sweeper, and a count.
- The catalog JSON re-authored to the new shape. No card's rules change.
- The catalog validator keeps refusing the nonsense the named variants used to prevent
structurally — a creature-only payload with a non-creature filter, min_power inside a static
ability's condition (Violation::PowerInStaticCondition, which exists because the computed
power reading would not terminate).
Deliberately not in scope: TargetSpec. That is #825, because it reaches the prompt
projection and the protocol mirror and this does not.
Acceptance criteria
Test and validation evidence
- The existing
m19_* behavioural suites are the primary net: this is behaviour-preserving, so
a green suite over re-authored JSON is the proof.
- New tests for the two capabilities the collapse unlocks (noncreature class, toughness bound),
each with a card-shaped scenario rather than a unit assertion on the filter.
- Validator tests for the pairings that must stay refused.
make compat; make verify.
Dependencies and sequencing
Should land after #820 and #822 so the catalog it re-authors is correct first. Sequence
before #825, which reuses the filter this introduces. Independent of #826 and #827, though
touching the same files as #827 — pick an order rather than running them in parallel.
Estimated scope
Large — broad change requiring a split assessment. It is split here: this half stops at the
engine boundary, and #825 carries the half that crosses into the server and the protocol.
Risks and constraints
Behaviour-preserving refactors of a selector are exactly where a silent scope widening hides —
a filter with an absent field means "any", and a named variant that used to mean "creatures"
becomes "every permanent" if the card type is forgotten during re-authoring. The catalog
re-authoring is the risky half, not the Rust. Consider landing the filter alongside the old
enums first and converting card by card with the suite green at each step.
Non-goals
TargetSpec (#825). The effect-verb matrix (#826, #827). CardFilter and
GraveyardScope/GraveyardCardClass, which select cards in zones rather than permanents and
are a third question.
Documentation impact
docs/card-schema.md — the selector vocabulary and "How far the vocabulary reaches" both
describe the named scopes and must describe the filter instead. exclusions.json — two entries
dropped. An ADR is warranted if the filter changes how the catalog is authored broadly enough
that the schema doc alone understates it; per AGENTS.md it is written after the code, not
before.
Goal
One composable permanent filter answers "which permanents?" for every non-targeting
selector, so a new mass effect, static ability, or count is a JSON change instead of an enum
variant plus a match arm in each consumer.
Context and evidence
Parent: #819, §2.1. This is the first of two halves; #825 does the targeting half, which is
the one that crosses the protocol seam.
Four enums answer the same question — which objects? — and none of them composes or shares a
matcher:
MassAffectsability/selector.rs:617PumpAll,GrantKeywordAll,RestrictAllStaticAffectsability/static_ability.rs:14DestroyAffectsability/selector.rs:273DestroyAllonly — two variants, one cardPermanentCountability/selector.rs:355PermanentCountis already the shape the other three want:The codebase already argues for exactly this.
ObservedActivation's doc comment:MassAffectswas not given that treatment, and its own doc records the consequence — "Itgrows by adding variants (attacking creatures, tapped creatures, …) as cards need them."
Counting the
affects.scopevalues actually used across the 299 catalog files: of the massscopes in use,
each_creature_token,creatures_without_flying,creatures_and_planeswalkers_your_opponents_control,attacking_creatures,creatures_that_player_controls,each_creature,each_artifact_or_enchantment,permanents_your_opponents_control, andattached_toare each used by exactly one card.DestroyAffectsexists only becauseMassAffectsis creature-only — its doc says so: "anon-creature scope there would make 'artifacts you control get +1/+1' an authorable sentence
that means nothing." A shared filter with a card-type field makes that a validator question
rather than a reason for a fourth enum, and deletes
DestroyAffectsoutright.exclusions.jsoncurrently names two limits that are limits of this shape rather than of therules:
permanents"
Both are one field on a shared filter.
Scope
PermanentFiltercovering the axes already modelled across the four enums: controllerrelation, card type, subtype, colour, a power bound, a counter, token-ness, and the
except_thisthat only a continuous ability has a "this" for.MassAffects,StaticAffects, andDestroyAffectsre-expressed in terms of it;DestroyAffectsdeleted.PermanentCountreconciled with it — one struct if the axes line up, or clearly one wrappingthe other with the difference stated.
matchespredicate, so a class means the same thing to a pump, a static ability, asweeper, and a count.
structurally — a creature-only payload with a non-creature filter,
min_powerinside a staticability's condition (
Violation::PowerInStaticCondition, which exists because the computedpower reading would not terminate).
Deliberately not in scope:
TargetSpec. That is #825, because it reaches the promptprojection and the protocol mirror and this does not.
Acceptance criteria
DestroyAffectsno longer exists.MassAffectsandStaticAffectsare expressed as one filter plus, for the static case,except_this.pass unchanged in intent, and any that had to change is explained.
authorable, and the
exclusions.jsonentry naming that limit is dropped.Violationand a test.make compatregenerated; supported and excluded stay disjoint.make verifygreen.Test and validation evidence
m19_*behavioural suites are the primary net: this is behaviour-preserving, soa green suite over re-authored JSON is the proof.
each with a card-shaped scenario rather than a unit assertion on the filter.
make compat;make verify.Dependencies and sequencing
Should land after #820 and #822 so the catalog it re-authors is correct first. Sequence
before #825, which reuses the filter this introduces. Independent of #826 and #827, though
touching the same files as #827 — pick an order rather than running them in parallel.
Estimated scope
Large — broad change requiring a split assessment. It is split here: this half stops at the
engine boundary, and #825 carries the half that crosses into the server and the protocol.
Risks and constraints
Behaviour-preserving refactors of a selector are exactly where a silent scope widening hides —
a filter with an absent field means "any", and a named variant that used to mean "creatures"
becomes "every permanent" if the card type is forgotten during re-authoring. The catalog
re-authoring is the risky half, not the Rust. Consider landing the filter alongside the old
enums first and converting card by card with the suite green at each step.
Non-goals
TargetSpec(#825). The effect-verb matrix (#826, #827).CardFilterandGraveyardScope/GraveyardCardClass, which select cards in zones rather than permanents andare a third question.
Documentation impact
docs/card-schema.md— the selector vocabulary and "How far the vocabulary reaches" bothdescribe the named scopes and must describe the filter instead.
exclusions.json— two entriesdropped. An ADR is warranted if the filter changes how the catalog is authored broadly enough
that the schema doc alone understates it; per
AGENTS.mdit is written after the code, notbefore.