Skip to content

engine: library-destination ChangesZoneAll triggers over-fire on within-library repositions, and the zone-change ledger is polluted (CR 400.7 / CR 701.20b) #7063

Description

@matthewevans

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions