Surfaced while proving the occurrence-exactness invariant of
filter_already_collected_trigger_events_from (crates/engine/src/game/triggers.rs).
Escalated rather than fixed: this is a production behaviour change on a
test-and-doc charter, the correct layer is an open design question, and it engages
the /add-trigger checklist. Anchors are at
84a4d01e63fd712936e58c9ab4e6834dc66e9e19.
Confidence: likely a defect, NOT fully adjudicated. The Oracle text below is
verified verbatim against Scryfall. No Gatherer/Scryfall ruling was consulted for
either card. Whoever picks this up should adjudicate before changing behaviour.
The rules position
A within-Library reposition — put a card from the library back into the library at a
new position — is zero moves. CR 400.7: "An object that moves from one zone to
another becomes a new object…" — the card never leaves the library. CR 701.20b:
"Revealing a card doesn't cause it to leave the zone it's in." The engine itself
says so, in its own annotation at zones.rs:1806-1808, and acts on it by NOT bumping
the incarnation for from == Zone::Library (zones.rs:1809-1812).
So nothing was "put into a library", and a
"whenever one or more cards are put into a library" trigger should not fire.
THE FIX MUST SPAN THREE LAYERS — gating the matcher alone would be the WRONG FIX
1. Matcher
zone_change_clause_matches (trigger_matchers.rs:1091-1130) has no same-zone
guard. Read in full at BASE_SHA it checks origin (:1106), destination equality
(:1110-1114), a destination constraint (:1115) and valid_card (:1118-1128),
then returns true (:1129). With origin = OriginConstraint::Any (what
trigger.origin == None + empty origin_zones produces), a Library -> Library
ZoneChanged is accepted. match_changes_zone_all (:1193) delegates straight
through match_changes_zone (:1133).
2. Ledger
move_to_library_at_index calls record_zone_change unconditionally —
zones.rs:1818-1819. The only from != Zone::Library guard on that path wraps
bump_incarnation (:1809-1812), not the recorder call. So a within-library
reposition also lands a ZoneChangeRecord { from_zone: Some(Library), to_zone: Library } row in state.zone_changes_this_turn.
3. Ledger consumers — the real blast radius
| Consumer |
Anchor |
Consequence |
QuantityRef::ZoneChangeCountThisTurn |
game/quantity.rs:4125-4140 (filter body :4129-4138; to.is_none_or(|zone| record.to_zone == zone) at :4131) |
Any "number of cards put into a library this turn" count over to: Library OVER-COUNTS — it includes within-library repositions. |
QuantityRef::ZoneChangeAggregateThisTurn |
game/quantity.rs:4145-4165 |
Same over-count, propagated into a sum/max/min. |
Ledger-subscript consumers, incl. self_ref_own_departure_successor |
types/ability.rs:23726, :23737, :23841, :23859 (production — that file's #[cfg(test)] boundary is :24224) |
Subscripts shift, because the ledger carries rows for non-moves. |
Gating the matcher would leave the ledger polluted and both QuantityRef counts
still over-counting.
The layer question is genuinely open
Candidate layers, none picked:
- suppress the
ZoneChanged emission for from == to at zones.rs:1829;
- skip the
record_zone_change call for from == to at zones.rs:1818-1819
— the only one that also fixes the ledger consumers;
- gate the matcher on
from != to at trigger_matchers.rs:1091-1130;
- gate at the collector.
Picking one without a design pass would be the "extend, don't hack" violation
CLAUDE.md forbids.
Affected cards (Oracle text verified verbatim against Scryfall)
- Wan Shi Tong, All-Knowing —
{3}{U}{U} Legendary Creature — Bird Spirit.
"Flying / When Wan Shi Tong enters, target nonland permanent's owner puts it into
their library second from the top or on the bottom. / Whenever one or more cards
are put into a library from anywhere, create two 1/1 colorless Spirit creature
tokens with "This token can't block or be blocked by non-Spirit creatures.""
- Dutiful Knowledge Seeker —
{2}{U} Creature — Fox Spirit.
"Whenever one or more cards are put into a library from anywhere, put a +1/+1
counter on this creature. / {3}: Put target card from a graveyard on the bottom of
its owner's library."
The source-level producer is verified at BASE_SHA:
try_parse_one_or_more_put_into_library (parser/oracle_trigger.rs:17019,
dispatched at :12594) sets def.mode = TriggerMode::ChangesZoneAll (:17077),
def.destination = Some(Zone::Library) (:17078) and def.batched = true
(:17081), and its own doc names Wan Shi Tong as its example.
PROVENANCE CAVEAT — carried deliberately. The exact parsed AST shape
({ mode: "ChangesZoneAll", origin: null, destination: "Library", batched: true, valid_card: null, trigger_zones: ["Battlefield"] }) was read from
data/card-data.json, which is gitignored, untracked, generated, and has no
BASE_SHA identity — the only copy is a build product regenerated from a tree
carrying uncommitted parser changes. The card text (Scryfall) and the
parser source (BASE_SHA) are authoritative; the artifact is not. Re-derive the
parsed shape from a clean regeneration before relying on it.
Reachability into a paused collector
Within-library repositions are produced during exactly the pauses that route into
triggers::collect_triggers_into_deferred — engine_resolution_choices::handles
(:656) covers ScryChoice, SurveilChoice, ClashCardPlacement,
TopOrBottomChoice, EffectZoneChoice, SearchChoice, RevealUntilKeptChoice,
DigChoice, DrawnThisTurnTopdeckChoice. The tutor-to-top class (e.g. Mystical
Tutor, Scryfall-verified: "Search your library for an instant or sorcery card,
reveal it, then shuffle and put that card on top.") performs one inside a search
continuation drain.
Related test coverage that exists today
within_library_repositions_are_separated_only_by_the_occurrence_index
(crates/engine/src/game/zones.rs, added in the #7058 follow-up) pins the shape of
this family at the emit site: zero bump, entered_incarnation None, identical
trigger_source_context, consecutive occurrence indices. It does not assert
anything about whether a trigger should fire — that is this issue.
Surfaced while proving the occurrence-exactness invariant of
filter_already_collected_trigger_events_from(crates/engine/src/game/triggers.rs).Escalated rather than fixed: this is a production behaviour change on a
test-and-doc charter, the correct layer is an open design question, and it engages
the
/add-triggerchecklist. Anchors are at84a4d01e63fd712936e58c9ab4e6834dc66e9e19.Confidence: likely a defect, NOT fully adjudicated. The Oracle text below is
verified verbatim against Scryfall. No Gatherer/Scryfall ruling was consulted for
either card. Whoever picks this up should adjudicate before changing behaviour.
The rules position
A within-Library reposition — put a card from the library back into the library at a
new position — is zero moves. CR 400.7: "An object that moves from one zone to
another becomes a new object…" — the card never leaves the library. CR 701.20b:
"Revealing a card doesn't cause it to leave the zone it's in." The engine itself
says so, in its own annotation at
zones.rs:1806-1808, and acts on it by NOT bumpingthe incarnation for
from == Zone::Library(zones.rs:1809-1812).So nothing was "put into a library", and a
"whenever one or more cards are put into a library" trigger should not fire.
THE FIX MUST SPAN THREE LAYERS — gating the matcher alone would be the WRONG FIX
1. Matcher
zone_change_clause_matches(trigger_matchers.rs:1091-1130) has no same-zoneguard. Read in full at BASE_SHA it checks origin (
:1106), destination equality(
:1110-1114), a destination constraint (:1115) andvalid_card(:1118-1128),then returns
true(:1129). Withorigin = OriginConstraint::Any(whattrigger.origin == None+ emptyorigin_zonesproduces), aLibrary -> LibraryZoneChangedis accepted.match_changes_zone_all(:1193) delegates straightthrough
match_changes_zone(:1133).2. Ledger
move_to_library_at_indexcallsrecord_zone_changeunconditionally —zones.rs:1818-1819. The onlyfrom != Zone::Libraryguard on that path wrapsbump_incarnation(:1809-1812), not the recorder call. So a within-libraryreposition also lands a
ZoneChangeRecord { from_zone: Some(Library), to_zone: Library }row instate.zone_changes_this_turn.3. Ledger consumers — the real blast radius
QuantityRef::ZoneChangeCountThisTurngame/quantity.rs:4125-4140(filter body:4129-4138;to.is_none_or(|zone| record.to_zone == zone)at:4131)to: LibraryOVER-COUNTS — it includes within-library repositions.QuantityRef::ZoneChangeAggregateThisTurngame/quantity.rs:4145-4165self_ref_own_departure_successortypes/ability.rs:23726,:23737,:23841,:23859(production — that file's#[cfg(test)]boundary is:24224)Gating the matcher would leave the ledger polluted and both
QuantityRefcountsstill over-counting.
The layer question is genuinely open
Candidate layers, none picked:
ZoneChangedemission forfrom == toatzones.rs:1829;record_zone_changecall forfrom == toatzones.rs:1818-1819— the only one that also fixes the ledger consumers;
from != toattrigger_matchers.rs:1091-1130;Picking one without a design pass would be the "extend, don't hack" violation
CLAUDE.mdforbids.Affected cards (Oracle text verified verbatim against Scryfall)
{3}{U}{U}Legendary Creature — Bird Spirit."Flying / When Wan Shi Tong enters, target nonland permanent's owner puts it into
their library second from the top or on the bottom. / Whenever one or more cards
are put into a library from anywhere, create two 1/1 colorless Spirit creature
tokens with "This token can't block or be blocked by non-Spirit creatures.""
{2}{U}Creature — Fox Spirit."Whenever one or more cards are put into a library from anywhere, put a +1/+1
counter on this creature. / {3}: Put target card from a graveyard on the bottom of
its owner's library."
The source-level producer is verified at BASE_SHA:
try_parse_one_or_more_put_into_library(parser/oracle_trigger.rs:17019,dispatched at
:12594) setsdef.mode = TriggerMode::ChangesZoneAll(:17077),def.destination = Some(Zone::Library)(:17078) anddef.batched = true(
:17081), and its own doc names Wan Shi Tong as its example.Reachability into a paused collector
Within-library repositions are produced during exactly the pauses that route into
triggers::collect_triggers_into_deferred—engine_resolution_choices::handles(
:656) coversScryChoice,SurveilChoice,ClashCardPlacement,TopOrBottomChoice,EffectZoneChoice,SearchChoice,RevealUntilKeptChoice,DigChoice,DrawnThisTurnTopdeckChoice. The tutor-to-top class (e.g. MysticalTutor, Scryfall-verified: "Search your library for an instant or sorcery card,
reveal it, then shuffle and put that card on top.") performs one inside a search
continuation drain.
Related test coverage that exists today
within_library_repositions_are_separated_only_by_the_occurrence_index(
crates/engine/src/game/zones.rs, added in the #7058 follow-up) pins the shape ofthis family at the emit site: zero bump,
entered_incarnationNone, identicaltrigger_source_context, consecutive occurrence indices. It does not assertanything about whether a trigger should fire — that is this issue.