Gate admin endpoints behind bearer token; add dependency scanning - #1
Closed
ntindle wants to merge 1 commit into
Closed
Gate admin endpoints behind bearer token; add dependency scanning#1ntindle wants to merge 1 commit into
ntindle wants to merge 1 commit into
Conversation
The /admin/* HTTP endpoints exposed full draft session state — including player reconnect tokens, enabling session hijacking — and force-delete with no authentication. Move them behind a PHASE_ADMIN_TOKEN bearer-token middleware that fails closed (404) when no token is configured and uses a constant-time comparison otherwise. The client-facing /p2p-draft-backup routes are left open. Add Dependabot (cargo, pnpm, npm, github-actions), a Security workflow (cargo audit gate + reported npm/pnpm audit), and a CodeQL workflow for the TypeScript surface. https://claude.ai/code/session_01RBXLPHJG2BxD5tot74aExY
ntindle
pushed a commit
that referenced
this pull request
May 28, 2026
Three reviewer findings on the prior PerTurnCastLimit work for Ethersworn Canonist: FINDING #1 (MEDIUM): the conditional-subject combinator inlined `tag("each player who has cast ")` and hard-coded `ProhibitionScope::AllPlayers`, bypassing the shared `strip_casting_prohibition_subject` building block. A future card phrased as "Each opponent who has cast ..." would have reduced to zero coverage. Restructured to strip the subject prefix via the shared helper first, then nom-match `who has cast (a|an) <SUBJ> spell this turn can't cast additional <OBJ> spells`. Two class tests (`each_opponent_scope`, `you_scope`) lock in the subject axis. FINDING phase-rs#2 (MEDIUM): CR citations referenced 101.2 + 604.1 ("can't" beats "can" + static enforcement), but the *authorizing rule for the casting prohibition itself* (CR 601.2 + CR 601.3a) was missing. Verified both rule numbers against docs/MagicCompRules.txt before adding. Updated citations on the combinator docstring, the caller's inline comment, and the swallow_check marker comment. LOW phase-rs#4 (carry-over): swallow marker was a bare `"PerTurnCastLimit"` substring — could false-positive on the literal string appearing in a description or unrelated location. Tightened to the serde external-tag shape `"\"PerTurnCastLimit\":{"` (and same for PerTurnDrawLimit), matching the precision class of the existing `"condition":{"type":"Unrecognized"` marker on the line above. Verification: - cargo fmt --all: clean - cargo clippy --all-targets -- -D warnings: clean - cargo test -p engine: 8187 lib + 422 integration + 7 doctest, all pass - oracle-gen for "ethersworn canonist": no Unimplemented, no parse_warnings, AST unchanged from baseline Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ntindle
pushed a commit
that referenced
this pull request
May 28, 2026
* feat(engine): implement Twinning Staff copy-count replacement
Twinning Staff's activated ability ({7},{T}: copy target instant/sorcery
you control) already worked, but its replacement line — "If you would
copy a spell one or more times, instead copy it that many times plus an
additional time. You may choose new targets for the additional copy." —
fell back to Effect::Unimplemented{name:"replacement_structure"}.
Implement it as a first-class CopySpell ReplacementDefinition carrying
QuantityModification::Plus{value:1}, mirroring the token/counter doubling
family (Doubling Season, Hardened Scales). The count is applied at the
copy-count chokepoint (the repeat_for loop in effects/mod.rs) via the new
helper copy_spell::copy_count_with_replacements, because copies are
produced through that loop rather than the ProposedEvent replacement
pipeline.
Correctness (CR 707.10 + CR 614.1a):
- only copies of a *spell* are bumped, not abilities (Gogo);
- only the copying player's own Staff applies ("if YOU would copy");
- the bumped count flows into total_iterations/resume stash, so each
additional copy runs the normal per-copy retarget step.
Tests: parser (line -> CopySpell/Plus{1}) + 3 engine helper tests
(count bump, opponent-Staff ignored, ability-copies excluded).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(engine): stop Twinning Staff runaway copy loop on targeted spells
Copying a *targeted* spell with Twinning Staff exploded into dozens of
copies (in-game "stuck in a loop"). Each copy pauses on CopyRetarget; the
drain driver resumes the next iteration by feeding a single-iteration
resume ability (repeat_for cleared) back through resolve_effect. The new
CopySpell count hook re-fired on every resumed iteration, re-adding the
"+1 additional copy" bonus each time and re-expanding the loop — runaway
copies (CR 614.6: a replacement applies to the copy event once, not per
copy).
Fix: add `copy_count_finalized` to ResolvedAbility. The repeat-loop
resume stash sets it, and the CopySpell count hook skips the bonus when
it is set, so the Twinning Staff bonus is folded into total_iterations
exactly once at the initial resolution. Untargeted copies (no pause) were
already correct; this only affects the pause/resume path.
Add a regression test that copies a targeted spell with Twinning Staff,
drives each retarget pause to completion, and asserts exactly two copies
(a runaway trips the loop guard). All ResolvedAbility struct literals get
the new field; `..ResolvedAbility::new(..)` spread sites are unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(engine): address review — typed CopyCountStatus, modular plural parse, zero-copy guard
Addresses PR review findings against the project's architectural rules:
- R2 (no bool fields): replace `copy_count_finalized: bool` on ResolvedAbility
with a typed `CopyCountStatus { Pending, Finalized }` enum, which expresses
the design space and reads self-documentingly at the count hook and the
repeat-loop resume stash.
- R1 / L2 (modular combinators + plural sibling coverage): rebuild the
"additional time(s)" parse from composed nom combinators along three
independent axes — count (`an` => 1, else a number), the `additional` token,
and the singular/plural `time(s)` noun — instead of full-phrase tags. Now
"plus an additional time" and "plus N additional times" both parse; added a
plural/numbered parser test.
- L4 (edge case): guard `copy_count_with_replacements` so the bonus does not
apply when the base copy count is zero (CR 614.6 — "if you would copy a spell
one or more times" has no event to replace at zero); added a regression test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(engine): correct Twinning Staff CR annotations to verified rules
R6 self-review of the Twinning Staff copy-count work: the "applies once,
not per copy" anti-runaway guard and the "one or more times" precondition
were both annotated CR 614.6, whose body ("a replaced event never happens")
describes neither claim.
Verified against docs/MagicCompRules.txt and corrected:
- "applies once / not invoked repeatedly per copy" -> CR 614.5 (a
replacement effect gets only one opportunity to affect an event)
- "one or more times" precondition / zero-copies guard -> CR 614.1 (a
replacement effect watches for an event that would happen)
Comment/doc-comment only; no logic change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(engine): avoid usize->u32 narrowing in copy_count_with_replacements
Keep the running copy count as usize and widen the u32 QuantityModification
values into it (value as usize, always lossless) instead of narrowing base
(usize) to u32 up front, which could truncate on 64-bit targets. Addresses
the gemini-code-assist review on PR #1. No behavior change for realistic
copy counts; removes a latent narrowing cast.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(PR-1067): harden copy-count replacement
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com>
ntindle
pushed a commit
that referenced
this pull request
May 28, 2026
…sconnects
Three closely-related pod draft bugs surfaced during a 2-seat draft:
1. Auto-pick by one seat advanced the round for everyone. The engine's
picks_this_round: u8 was a counter, not a seat-identity set — after
pod_size picks from any combination of seats it declared "round
complete" and passed packs, even though only the host had picked.
The other player ended the draft with 0 picks.
2. Pick timer hit 0 with nothing happening. autoPickAllPending tried
to re-pick for seats that had already picked, the engine errored,
console.error swallowed it, and the timer was never restarted.
3. A player dropped mid-draft and the host UI showed no change.
DraftSeat::Human.connected was hardcoded `true` at every construction
site and never mutated; DraftPodPage never read paused/pauseReason.
Engine (crates/draft-core):
- Introduce SeatFlags newtype (#[serde(transparent)] over Vec<bool>)
with Vec::resize-style ensure_len semantics. Two parallel fields
share it: seats_picked_this_round (replaces picks_this_round counter)
and connected_seats (new authoritative runtime connection state).
- pick_pass.rs: reject duplicate picks per round with
SeatAlreadyPickedThisRound; advance the round only when every seat
with a non-empty current_pack has picked. Lazy ensure_len on first
access handles upgrade from old-shape saves.
- Add DraftAction::SetSeatConnected + DraftDelta::SeatConnectionChanged.
Rejects bot seats with DraftError::SeatIsBot.
- Remove DraftSeat::Human.connected; view.rs sources connected from
connected_seats. Single source of truth.
- Add DraftPauseReason typed enum (PlayerDisconnected, PausedByHost,
DisconnectGraceExpired) — default PascalCase serde matching every
other enum in the file.
Server (crates/server-core):
- Drop the dead connected: i == 0 / connected: true literals.
- Mirror wrapper-side connected writes through SetSeatConnected so
the engine view reflects runtime connection state.
WASM bridge (crates/draft-wasm):
- Export set_seat_connected.
Host adapter (client/src/adapter/p2p-draft-host.ts):
- handleGuestDisconnect: void-IIFE setSeatConnected(false) + broadcast,
matching the existing IIFE pattern at lines 342/1267.
- handleReconnect: setSeatConnected(true) before getViewForSeat so the
reconnect_ack carries the updated snapshot; broadcastViews after.
- autoPickAllPending: skip seats already in picksThisRound;
broadcastViews after non-empty sweep.
- Replace raw English reason strings with DraftPauseReason variants
at lines 678, 685-686, 1255-1256, 1391.
UI (client/src/pages/DraftPodPage.tsx, components/draft/SeatStatusRing.tsx):
- Amber paused banner reads `t(pauseReason.${pauseReason})` — wire
shape = i18n key = enum variant, no boundary conversion.
- Per-seat status dot turns rose-400 on disconnect; name strikethrough.
- i18n keys in en (full) + de/es/fr/it/pl/pt (stubbed with English).
Store (client/src/stores/multiplayerDraftStore.ts):
- pauseReason: DraftPauseReason | null.
- Existing test updated for typed reason.
Tests:
- Engine (cargo test -p draft-core, 114 pass):
* pick_twice_from_same_seat_returns_error
* single_seat_cannot_force_pack_pass (Bug #1 regression)
* round_completes_only_when_all_seats_with_packs_pick
* bot_seat_satisfies_round_complete_predicate
* mid_round_resume_treats_all_seats_as_not_yet_picked
* set_seat_connected_updates_state_and_emits_delta
* set_seat_connected_out_of_range_errors
* set_seat_connected_on_bot_seat_errors
* seat_flags_resize_preserves_existing_entries
- Frontend (pnpm test --run, 1018 pass): existing draft store + adapter
tests continue to pass with the typed pauseReason.
- Server-core (cargo test -p server-core, 132 pass).
Versioning: new code reads old saves (new fields default via serde,
removed field ignored). Old code cannot read new saves (no
#[serde(default)] on the removed picks_this_round). Wire-broadcast
DraftPlayerView does not include either field, so guests on older
builds are unaffected.
Out of scope (captured for follow-up):
- Retire host-side picksThisRound: Set by exposing engine per-seat
pick status in SeatPublicView.pick_status.
- Wire-trust gate on SetSeatConnected in multiplayer server's
handle_draft_action filter.
- Remove server-core wrapper connected: Vec<bool> cache.
ntindle
pushed a commit
that referenced
this pull request
May 29, 2026
…hase-rs#1266) * parser: add 'is/are returned to [possessive] hand' trigger condition Add try_parse_returned_to_hand() for bounce-trigger zone-change events (CR 603.6c + CR 603.10a). Handles possessive variants: your hand, a player's hand, its/their owner's hand, bare hand. Cards unlocked: Warped Devotion, Azorius Aethermage, Stormfront Riders, Tameshi Reality Architect. * review: shared parse_hand_possessive, merge owner into valid_card, fix CR format Address review comments on PR phase-rs#1266: 1. Gemini #1: Extract local parse_returned_hand / parse_hand_possessive into a shared module-level parse_hand_possessive() that both try_parse_put_into_hand_from and try_parse_returned_to_hand call. Returns Option<ControllerRef> for cleaner semantics. 2. Gemini phase-rs#2: Reformat doc comment to CR <number>: <description> style. 3. Codex #1: Move hand-owner constraint from valid_target (not read by match_changes_zone) to valid_card via add_controller(), so the zone-change matcher correctly filters by the bounced permanent's controller. --------- Co-authored-by: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com>
ntindle
pushed a commit
that referenced
this pull request
May 29, 2026
Add a Labeling section (bug=existed-but-broken, enhancement=new engine work, feature=larger-scoped, test=test-only, refactor) applied to every handled PR, and correct the Enqueue step: main is REVIEW_REQUIRED, so an unapproved PR is silently shed from the queue. Mandate approve -> label -> enqueue and verify via GraphQL (gh CLI stdout unreliable under rtk). docs(skill): make 'correct architectural location' the top enqueue gate Per maintainer: the #1 review check is that a fix lives at the correct architectural seam, not just that CI is green. Velocity never justifies merging technical debt — a wrong-location fix that ships is worse than no fix. Adds the right-seam question as the first Architecture Review prompt and a disqualifying enqueue-checklist gate, citing the phase-rs#1251 precedent.
matthewevans
added a commit
that referenced
this pull request
Jun 1, 2026
…th (phase-rs#1748) * perf(engine): skip GameObject clone on owner-zone filter fast path (phase-rs#1747) `matches_target_filter_in_owner_zone` cloned the full GameObject on every call just to override `controller := owner` for owner-scoped (hand / library / graveyard) filter matching — allocating `name`, the `counters` HashMap, and several Vecs per object. This is hot on library scans for tutors/search effects (Diabolic Tutor, Cultivate), where the clone runs once per scanned card. When `controller == owner` — the overwhelmingly common case for objects in owner zones, where control-change effects almost never apply — the override is a no-op, so the clone is pure waste. Add a fast path that calls `filter_inner_for_object` against the borrowed object directly, skipping the clone. Behavior is identical: the override only changes the result when `controller != owner`, which still takes the clone path. Adds a regression test exercising both paths (CR 109.5 / CR 400.3 owner scoping preserved: a control-changed card in an owner zone still counts as its owner's). This is bottleneck #1 of the six in the report; the remaining items (Arc<GameEvent> triggers, DifferentNameFrom memoization, layer-flush escalation, SBA single-scan, NameMatchesAnyPermanent battlefield-only iteration) are higher-risk refactors that warrant profiling/benchmarks before landing and are deferred to follow-ups. Closes phase-rs#1747 * fix(test): borrow-safe id binding + rustfmt for owner-zone perf test - bind CardId(state.next_object_id) before the &mut state borrow in create_object (E0502: cannot read state while mutably borrowed) - keep both assert_eq!/assert_ne! comparison operands on one line per rustfmt * fix(PR-1748): preserve owner-zone filter scoping with LKI --------- Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com>
matthewevans
added a commit
that referenced
this pull request
Jun 8, 2026
…phase-rs#2672) Derived from a week-long retrospective of the AI-contribution pipeline (Gemini, /review-impl, /pr-contribution-handler) over ~2,000 PR comments. Stops the most-flagged author-side defects at the source. - engine-implementation-executor: blocking discriminating-test gate (the #1 finding across every reviewer was AST-shape-only tests that pass when the fix is reverted) and CR-annotation diff gate (catches hallucinated subparts like CR 702.808); extend the parser-diff-gate regex to rfind/split/split_once (check-parser-combinators.sh blind spots). - engine-implementer: Step 4 confirms both new gates before commit. - pr-contribution-handler: duplicate-PR + scope-contamination intake gate and enqueue item; post comments via --body-file (shell backtick mangling) and batch comment-fetch (rate-limit). - review-impl: codify CR +// compound-annotation convention so Gemini's format pedantry is refuted, not echoed. - AI-CONTRIBUTOR: contributor-side duplicate-PR check (3.1), branch off current upstream/main (4), and test-discrimination + rfind/split in the 5 cross-check.
matthewevans
added a commit
that referenced
this pull request
Jun 12, 2026
…se-rs#2824) * refactor(engine): route single-authority bypasses through canonical entries - explore.rs: replace hand-rolled library->hand zone mutation with zones::move_to_zone (emits ZoneChanged + runs zone-exit cleanup) - layers: drop the unused evaluate_layers re-export; migrate the two production direct callers (become_copy, engine_replacement) to flush_layers / mark_layers_full + flush_layers; doc-reserve direct evaluate_layers calls for tests - ability_utils: funnel validate_selected_targets{,_for_ability} count validation through one shared inner body (targeting.rs pattern) - engine_resolution_choices: route the two synthetic-ability resolver bypasses through resolve_ability_chain; doc resolve_effect's contract - keywords: document the object-scoped vs state-scoped query tiers * feat(parser): single authority for expressing unparsed text Effect::unimplemented(name, fragment) is the one way to construct the Unimplemented effect; name is a stable pattern-class key consumed by coverage gap categorization. Delete the dead parse_or_unimplemented / option_to_nom boundary fns (zero callers) and fix the stale CLAUDE.md oracle_nom/error.rs description. * ci: extend parser gate + add engine-authority and skill-doc gates - check-parser-combinators.sh: close .rfind/.split/.splitn blind spots; new family (E) verbatim-sentence equality (== "25+ chars"); new family (F) hand-constructed Effect::Unimplemented literals - check-engine-authorities.sh (new): diff gate forbidding raw .keywords.contains/.iter queries outside the keyword authorities - check-skill-doc.sh (new): asserts oracle-parser SKILL.md matches the parser tree (paths, symbols, priority-table row parity); SKILL.md regenerated against the code (38-slot priority table, IR layer, clause_shell, oracle_static/ split, single-authority doctrine) * refactor(parser): make condition bridges exhaustive over their source enums static_condition_to_trigger_condition, static_condition_to_ability_condition, and ability_condition_to_static_condition no longer hide unbridged variants behind wildcards: adding a StaticCondition/AbilityCondition variant is now a compile error at each bridge, forcing a conscious bridging decision. All expanded arms map to None exactly as the wildcards did (no behavior change). * refactor(engine): cost-payment Phase 0 — sorcery-speed delegation + pay_from_pool rename Phase 0 of the cost-payment unification plan (.planning/cost-payment-unification): - casting.rs: CastingProhibitionCondition::NotSorcerySpeed now delegates to restrictions::is_sorcery_speed_window instead of re-deriving the CR 307.1 timing predicate inline — restrictions.rs is the single timing authority. - mana_payment.rs: pay_cost → pay_from_pool (pool-level arithmetic only), freeing the pay_cost name for the future game/costs.rs ability-cost payment authority. Callers + re-export updated. * refactor(parser): consolidate three parallel duration grammars into oracle_nom/duration.rs Single authority for Oracle duration phrases (CR 611.2 / CR 611.2b / CR 514.2): the prefix-nested nom grammar in oracle_nom/duration.rs now owns every phrase→Duration mapping. strip_leading_duration / strip_trailing_duration in oracle_effect/lower.rs are positional-only wrappers (word-boundary scan + quantity-clause guard; the two phrase tables are deleted), and the three inline re-encodings in oracle_effect/subject.rs delegate to the grammar. Conflict between the old grammars resolved in the strip table's (test-pinned) favor: 'for as long as you control ~' maps to UntilHostLeavesPlay. That is a pre-existing imprecision vs CR 611.2b (control loss without leaving the battlefield should end it) — now visible in exactly one place. * refactor(parser): replace verbatim sentence clusters with nom single authorities - 'Your speed can increase beyond 4' (CR 702.179d-e) was string-equality encoded at three sites (two oracle.rs routing predicates + the semantic parse in oracle_static/dispatch.rs); now one is_speed_unlock_sentence combinator in dispatch.rs, called by all three. - The ChooseFromZone dig continuation 'put the rest on the bottom of your library [in a random order | in any order]' (CR 401.4) was three full-line equalities in sequence.rs; now one all_consuming combinator with the order suffix as an opt(alt(...)) axis. * refactor(engine): zone-change pipeline Phase A — carve out game/zone_pipeline.rs Zero-behavior-change carve-out per .planning/zone-change-pipeline/PLAN.md: execute_zone_move / deliver_replaced_zone_change / apply_zone_delivery_tail and friends move verbatim from effects/change_zone.rs into the new game/zone_pipeline.rs module (one mechanical super::→crate:: path fix); a pub(crate) use shim keeps every existing caller compiling unchanged. Seeds the Phase B+ vocabulary (not yet consumed, scoped #[allow(dead_code)]): ZoneMoveRequest + builders, ZoneChangeCause (CR-annotated exemption split), EntryMods (EtbTapState, not bool), ExileLinkSpec, DeliveryCtx, and the ApprovedZoneChange proof token (no Serialize/Deserialize/Clone/Default; private event field; pub(crate) approve_post_replacement mint path). Independently reviewed: APPROVED. Phase B watch-item: move_object must seed EtbTapState onto the ProposedEvent directly instead of round-tripping through execute_zone_move's legacy bool boundary. * refactor(types): unify Effect::AddCounter into Effect::PutCounter (CR 122.1) Effect::AddCounter and Effect::PutCounter expressed the same operation (place counters on an object) through two variants, forcing every match site to handle both — and several AI policy sites silently handled only one (cast_facts, redundancy_avoidance had AddCounter-only legs with PutCounter receiving different treatment). One variant, one meaning: PutCounter is the single authority, with #[serde(alias = "AddCounter")] accepting legacy serialized data. All ~70 match/construction sites across engine, phase-ai, and mtgish-import migrated; duplicate arms produced by the rename were collapsed (compiler-verified via -D unreachable-patterns). Engine inventory regenerated; the two remaining AddCounter entries are unrelated enums (ReplacementEvent / game event). * refactor(parser): rewrite parse_restriction_modes as negation x verb-phrase list grammar Replace ~14 verbatim equality clusters ('can't attack or block', 'can't block or be blocked', ...) with a composed nom grammar: alt(("can't", "cannot")) + separated_list1 over restriction atoms (attack / block / be blocked / be sacrificed / be enchanted / be equipped / be countered / transform / crew). Any combination of atoms now parses — including orders no current card uses — instead of only the enumerated permutations. Elided-'be' English ellipsis ('can't be equipped or enchanted') handled via a bare-atom fallback inside the list parser. Also freezes oracle_quantity.rs for new grammar: new quantity recognition belongs in oracle_nom/quantity.rs (module doc). * refactor(parser): delete zero-caller parse_target_phrase wrapper oracle_target::parse_target is the single authority for 'target <type phrase>' extraction; the unused nom wrapper was a second grammar waiting to drift. Its tests retargeted to parse_type_phrase, the building block production actually calls. * ci: make coverage ratchet informational on push-to-main On push-to-main the regression check can only do harm: the merge has already happened, and a failing check blocks the R2 baseline republish that runs later in the same job — wedging main red until someone manually uploads a baseline (the recurring PR #2339 / PR #2802 swallowed-clause ratchet deadlock). continue-on-error on main pushes lets the baseline self-heal after an intentional ratchet absorption, while PR and merge_group runs remain fully blocking. * refactor(types): replace sorcery_speed bool with ActivationRestriction::AsSorcery single authority CR 602.5d: 'Activate only as a sorcery' timing is now represented solely by ActivationRestriction::AsSorcery in AbilityDefinition.activation_restrictions. The parallel sorcery_speed display bool is deleted; is_sorcery_speed() queries the restriction. A hand-written Deserialize (AbilityDefinitionDe mirror) tolerates the legacy serialized field and migrates sorcery_speed:true into AsSorcery (dedup). Serialized exports no longer emit the key; snapshots and the integration card fixture follow. * refactor(types): fold ActivationCadence into ActivationRestriction (CR 602.5b) CR 602.5b: once-each-turn activation cadence on keyword actions (Crew) is now expressed with the existing ActivationRestriction::OnlyOnceEachTurn instead of the parallel ActivationCadence enum, which is deleted. Keyword::Crew's once_per_turn becomes Option<Box<ActivationRestriction>> (None = unrestricted; boxed to break the Keyword -> ActivationRestriction -> ParsedCondition -> Keyword size cycle). The custom Crew deserializer accepts both the legacy ActivationCadence tagged shape ({"type":"Unlimited"}/{"type":"OncePerTurn"}) and the new Option<ActivationRestriction> shape. Regenerated integration card fixture carries the new export shape (and drops the legacy sorcery_speed key). * docs(engine): fix stale sorcery_speed display-flag comment (review follow-up) * refactor(engine): seed three-state EtbTapState directly onto ZoneChange proposal (Phase B watch-item) execute_zone_move took `effect_enter_tapped: bool` and could only ever set EtbTapState::Tapped, collapsing the Unspecified-vs-Untapped distinction the pipeline carrier (ProposedEvent::ZoneChange.enter_tapped: EtbTapState) exists to preserve. move_object further round-tripped its req.mods.enter_tapped (EtbTapState) through .is_tapped() before passing it in. Thread EtbTapState end-to-end: execute_zone_move now accepts EtbTapState and seeds it onto the proposal whenever it is not Unspecified (CR 614.1). All callers that already hold an EtbTapState (change_zone resolver paths, seek) pass it through unchanged; bool-literal callers pass EtbTapState::Unspecified. move_object passes req.mods.enter_tapped directly. No bool round-trip remains. This is the recorded Phase A review condition for proceeding with Phase B. * refactor(engine): route destroy.rs delivery through zone pipeline proof token (Phase B) apply_destroy_after_replacement delivered its inner (post-replacement) ZoneChange with a bare zones::move_to_zone in both arms (the post-Destroy inner move and the outer-replacement-redirected-to-ZoneChange move). A destruction redirected to the battlefield (CR 614.6) therefore dropped the entire delivery tail: CR 614.1c enters-with-additional-counter statics, enter_tapped / enter_with_counters, exile-link tracking, and the post-replacement-continuation drain. Seal each already-replaced ZoneChange via ApprovedZoneChange::approve_post_replacement (preserving its `applied` set and re-validating it is a ZoneChange) and deliver through zone_pipeline::deliver — the single delivery tail. A NeedsChoice from the tail propagates as `false` (state.waiting_for already set) so the caller does not advance. Adds a discriminating integration test (destroy_redirect_to_battlefield_delivery_tail) that casts "Destroy target creature" at a victim with a Moved->Battlefield redirect under a CR 614.1c additional-+1/+1-counter static, and asserts the redirected creature receives the counter — fails on the old raw move (0 counters), passes through the tail. CR 701.8a (line 3315), CR 614 / 614.6 (line 3072), CR 614.1c (line 3056). * refactor(engine): route sacrifice.rs delivery through zone pipeline proof token (Phase B) apply_sacrifice_after_replacement delivered its inner (post-replacement) graveyard ZoneChange — and the outer-redirect ZoneChange — with a bare zones::move_to_zone. A sacrifice redirected to the battlefield (CR 614.6) therefore dropped the delivery tail (CR 614.1c enters-with-additional-counter statics, enter_tapped / enter_with_counters, post-replacement-continuation). Seal each already-replaced ZoneChange via ApprovedZoneChange::approve_post_replacement and deliver through zone_pipeline::deliver. The Sacrifice proposal carries no source, so no exile-link context is attributed. A NeedsChoice from the tail returns SacrificeApply::NeedsChoice (state.waiting_for already set) so the caller pauses. Adds a discriminating test (sacrifice_redirected_to_battlefield_applies_enters_with_counters_tail) driving the real sacrifice_permanent pipeline with a Moved->Battlefield redirect under a CR 614.1c additional-+1/+1-counter static — fails on the old raw move (0 counters), passes through the tail. CR 701.21a (line 3445), CR 614 / 614.6 (line 3072), CR 614.1c (line 3056). * refactor(engine): route sba.rs lethal-damage delivery through zone pipeline proof token (Phase B) The lethal-damage state-based-action destruction loop (check_lethal_damage) delivered its inner (post-replacement) ZoneChange with a bare zones::move_to_zone. A lethal-damage death redirected to the battlefield (CR 614.6, Rest in Peace / "would die -> return" class) therefore dropped the delivery tail (CR 614.1c enters-with-additional-counter statics, enter_tapped / enter_with_counters, post-replacement-continuation). Seal the already-replaced ZoneChange via ApprovedZoneChange::approve_post_replacement (source carried from the Destroy event) and deliver through zone_pipeline::deliver. Per CR 704.3, completing all SBAs may require a replacement choice surfaced by the delivery tail (CR 614.12a Devour as-enters); the NeedsChoice arm pauses exactly as the existing regeneration NeedsChoice arm does (state.waiting_for already set by the tail). Adds a discriminating test (sba_lethal_damage_redirected_to_battlefield_applies_enters_with_counters_tail) driving check_lethal_damage directly with a Moved->Battlefield redirect under a CR 614.1c additional-+1/+1-counter static — fails on the old raw move (0 counters), passes through the tail (exactly 1). CR 701.19b (line 3426), CR 704.3 (line 5457), CR 704.5g (line 5476), CR 614 / 614.6 (line 3072), CR 614.1c (line 3056), CR 614.12a (line 3099). * docs(engine): correct SBA delivery CR annotation to 704.5g + 614.6 (review follow-up) 701.19b is regeneration-via-static; the delivered event here is a lethal-damage destruction (CR 704.5g) whose redirect is a replacement (CR 614.6). * refactor(engine): hoist token + B->B no-op guards into move_object preflight (Phase C0a, Risk #11) The unified pipeline runs replace_event ahead of zones::move_to_zone's delivery-time guards. A one-shot replacement could therefore be consumed (last_effect_count, CR 616.1 choices) on a move the primitive then rejects as a no-op. Hoist the two cheap, side-effect-free object-level guards (CR 111.8 token-outside-battlefield cease-to-exist, CR 603.2g + CR 603.6a Battlefield->Battlefield no-op) into move_object's preflight, before the execute_zone_move replacement consult. Behavior-neutral in Phase C0a (move_object has no production callers yet); the guards take effect as the bucket-B effect sites migrate in C1+. CR 111.8 (line 663), CR 603.2g (line 2573), CR 603.6a (line 2595). * fix(engine): clear damage on lethal-damage death redirected to battlefield (Phase C0b, CR 614.5) A lethal-damage SBA destruction redirected by a Moved replacement back to the battlefield delivers a Battlefield->Battlefield ZoneChange. zones::move_to_zone's CR 603.2g no-op guard returns before reset_for_battlefield_entry, so the creature keeps its marked damage. The SBA fixpoint then re-derives lethal damage and re-fires the destruction-replacement on every iteration (counter / event stacking, capped at MAX_SBA_ITERATIONS=9) — a pre-existing CR 614.5 violation (a replacement gets only one opportunity), surfaced visibly by the Phase B delivery-tail routing because the enters-with-additional-counter static now applies on each re-entry. Root cause: the no-op guard is correct for a spurious self-loop (Coiling Oracle) but the redirected death is a genuine leave-and-re-enter — a new object per CR 400.7 whose damage is gone. After delivering the redirect, clear the marked damage the no-op delivery left behind so the fixpoint sees no lethal damage and the one-shot replacement is not re-applied. Adds a discriminating test (sba_lethal_damage_redirect_to_battlefield_applies_counter_exactly_once) driving check_state_based_actions with a Moved->Battlefield redirect under a CR 614.1c additional-+1/+1-counter static: pre-fix the creature ends with Some(9) counters (9 re-fires); post-fix exactly Some(1). CR 614.5 (line 3069), CR 400.7 (line 1948), CR 603.2g (line 2573), CR 704.5g. * fix(engine): route mill delivery through zone pipeline so Moved redirects fire (Phase C1) mill::apply_mill_after_replacement delivered each milled card with a bare zones::move_to_zone, which never proposed a per-card ZoneChange. Moved-level redirects ("if a card would be put into a graveyard from anywhere, exile it instead" — Rest in Peace / Leyline of the Void class) were therefore silently dropped for milled cards: a milled card reached the graveyard even with Rest in Peace in play (PLAN §8 Risk #1; confirmed bug). Route each milled card through zone_pipeline::move_object, which proposes the inner ZoneChange and consults the Moved replacements before delivery. The milled card itself anchors the Effect cause (mill to a graveyard creates no exile-link and a Moved replacement's valid_card is evaluated against the moved card, so this matches the pre-pipeline raw attribution while enabling the consult). A per-card NeedsChoice (CR 616.1 multi-replacement race on one milled card) is parked (state.waiting_for already set) and stops the batch; no real card produces this on a library->graveyard mill, so the resumable batch-continuation (PLAN §5a / Risk #10) is deferred. Discriminating test asserts non-interactivity via the mandatory RIP redirect. Adds mill_honors_rest_in_peace_graveyard_to_exile_redirect: drives the real Mill pipeline with a global graveyard->exile Moved replacement and asserts milled cards land in EXILE (graveyard empty) — fails on the old raw move (graveyard). Also removes the now-live #[allow(dead_code)] on zone_pipeline::move_object. CR 701.17a (line 3402), CR 614.6 (line 3072), CR 616.1 (line 3169). * fix(engine): resume mill batch across per-card replacement choices instead of stranding (Phase C1 review fix) The C1 mill migration bailed with 'return Ok(())' when a per-card Moved replacement surfaced a choice, on the false claim that no real card produces one: the CR 616.1 materiality classifier treats ANY destination-redirecting Effect::ChangeZone as Unconditional-material, so two simultaneously-applicable graveyard->exile redirects (Rest in Peace + Leyline of the Void — a real, common combination) prompt for ordering on EVERY milled card. Pre-fix the first card's prompt never even surfaced (the pause set pending_replacement but no caller parked waiting_for) and cards 2..N stranded in the library. Implement the PLAN §5a batch continuation (option a): - mill's per-card delivery loop now parks the prompt via replacement::park_waiting_for and stashes the undelivered tail in the new state.pending_mill_deliveries (PendingMillDeliveries { remaining, destination }). - handle_replacement_choice drains the parked tail after the chosen event delivers (Execute arm, following the established pending-queue drain pattern; Prevented arm analogous) via mill::drain_pending_mill_deliveries, re-parking when the next card surfaces its own prompt. Discriminating test mill_under_two_graveyard_redirects_delivers_every_card_through_ordering_choices: mills 3 cards under RIP + Leyline, answers each CR 616.1 ordering prompt, and asserts ALL milled cards leave the library and end in exile with the tail fully drained. Pre-fix it fails (no prompt surfaced; cards stranded). CR 701.17a (line 3402), CR 616.1 (line 3169), CR 614.6 (line 3072). * docs(engine): scope C0b damage scrub as degenerate self-redirect guard, not CR 400.7 re-entry (review fix) The C0b comment justified clearing damage_marked via the CR 400.7 new-object rule, contradicting the chosen delivery: the Battlefield->Battlefield no-op means reset_for_battlefield_entry never ran, so incarnation epoch, summoning sickness, counters, and entered_battlefield_turn are all stale — claiming a new object while delivering a stale one is inconsistent. Reword to the narrow reading: a 'remains on the battlefield instead of dying' replacement is regeneration-shaped — CR 701.19a/b replaces destruction with 'remove all damage marked on it' while the permanent STAYS the same object — so the two-field damage scrub matches that semantics without claiming a new-object re-entry. Add a TODO at the site recording the staleness (and the delivery tail's CR 614.1c counter re-application) as the open question for when a real would-die->battlefield redirect card class appears: no card currently parses to one (parser builds die->exile / shuffle-back; Persist/Undying are dies-triggers), so the semantics decision is deliberately not baked in on zero real cards. Test doc header updated to match. Comment-only change; behavior unchanged (test still passes: exactly one CR 614.1c counter per SBA fixpoint). CR 701.19a/b (lines 3424/3426), CR 603.2g (line 2573). * docs(engine): correct mill NeedsAuraAttachmentChoice stash comment (round-2 review P2) The stash comment claimed reaching the aura-attachment-choice arm 'fails loudly (undrained tail)'. It does not: an aura-host choice surfaces as WaitingFor::ReturnAsAuraTarget, not the replacement-choice path, so drain_pending_mill_deliveries (only invoked from handle_replacement_choice) never fires for it. A stale pending_mill_deliveries would instead be silently drained by the NEXT unrelated replacement-choice resume. The arm is dead code for every parsed Mill today (Battlefield is not a Mill destination); document the real behavior so a future battlefield-mill variant is understood as a bug to surface rather than a self-healing path. CR 303.4f (line 822). * fix(engine): propagate mill per-card pause through apply_mill_after_replacement (round-2 review P1) The nested Mill-event resume path clobbered a per-card mill park. When a Mill-event replacement (e.g. a mill-doubler) resolves through a CR 616.1 ordering choice, handle_replacement_choice's Mill arm applied the accepted event with 'let _ = apply_mill_after_replacement(...)', discarding the helper's pause signal, then unconditionally reset state.waiting_for to Priority (~:392). If deliver_mill_cards parked a per-card prompt inside that arm (two simultaneously-applicable graveyard->exile redirects make every milled card prompt for CR 616.1 ordering), the reset stranded the first paused card. Plumb deliver_mill_cards's existing pause bool out through apply_mill_after_replacement (now returns Result<bool, EffectError>; true = fully delivered, false = parked) and early-return from the Mill arm on a pause, mirroring the apply_etb_counters early-return precedent. EffectError has no EngineError conversion at that arm, so the error is mapped to 'delivered' (preserving the prior let _ swallow) and only the pause is acted on. mill::resolve also bails before EffectResolved on a per-card pause so it doesn't emit a resolution event over a parked prompt. Reachable only with two simultaneous Mill-event replacements that both prompt, which no parsed card produces, so a full runtime repro is impossible. The unit test apply_mill_after_replacement_reports_per_card_pause_to_caller drives the shared seam directly: under two graveyard->exile redirects the first milled card surfaces a CR 616.1 prompt, asserting the helper returns false, leaves waiting_for set to that prompt, and parks the tail — the exact contract the Mill arm's early-return now depends on. CR 701.17a (line 3402), CR 616.1 (line 3169), CR 614.6 (line 3072). * docs(engine): confirm rad-counter mill read is park-safe (round-2 review P3) rad_counters assesses CR 728.1 life loss from library_before (a pre-mill snapshot) immediately after apply_mill_after_replacement. Document that this read remains correct even if the mill parks mid-batch on a per-card CR 616.1 ordering choice: milled_ids is fixed by the post-replacement count against the snapshot (identifying the correct top-N cards regardless of delivery routing), and the per-card life-loss loop reads card type from state.objects, which is zone-independent. The parked tail is delivered by the resume path; the snapshot predates any delivery. CR 728.1 (line 6239). * fix(engine): route countered spell to graveyard/exile through zone pipeline (Phase C3) counter::resolve and resolve_all moved a countered spell off the stack with a raw zones::move_to_zone, which never proposed a per-card ZoneChange. Moved-level graveyard redirects (Rest in Peace / Leyline of the Void: 'if a card would be put into a graveyard from anywhere, exile it instead') were therefore silently dropped for countered spells: a countered spell reached the graveyard even with Rest in Peace in play (PLAN §8 Risk #3 — graveyard-redirect class, confirmed bug). Route the stack -> graveyard/exile move through zone_pipeline::move_object so the inner ZoneChange is proposed and Moved replacements are consulted before delivery. The exile-on-counter destination (CR 702.34a/127a/180a Flashback / Aftermath / Harmonize) is a static destination rule, not a replacement, so it is still selected before the consult. SpellCountered now fires immediately on stack removal (the counter itself) rather than after the consequent move, so a CR 616.1 ordering pause during delivery does not drop it. A single applicable redirect never prompts; only two simultaneous redirects produce a CR 616.1 choice — that mass multi-card continuation is the Phase C4 class (bail on pause; no parsed card combines mass counter with a double graveyard redirect today). Discriminating test countered_spell_honors_rest_in_peace_graveyard_to_exile_redirect: counters a spell with a global graveyard->exile Moved redirect on the battlefield and asserts the spell ends in EXILE (graveyard empty). FAILS on the pre-C3 raw move (spell reaches the graveyard, redirect dropped). CR 608.2b (line 2807), CR 614.6 (line 3072), CR 616.1 (line 3169), CR 701.6a (line 3309). * fix(engine): route mass bounce through zone pipeline + generalize batch continuation (Phase C4) bounce::resolve_all delivered each mass-bounced permanent with a raw zones::move_to_zone under a comment claiming 'no replacement-pipeline detour is needed because mass-bounce events are not destruction events (CR 614.6 doesn't apply here)'. That justification was wrong by citation: CR 614.6 governs replacement semantics generally, and CR 614.1 replacements watch zone-change *events*, not only destruction. The raw move never proposed a per-object ZoneChange, so Moved redirects watching the bounce destination ('if a permanent would be returned to a hand, exile it instead' class) silently never fired (PLAN §8 Risk #4). The single-target bounce raw moves (battlefield/graveyard/ stack -> destination) had the same gap. Route every bounce move through the pipeline. mass bounce uses a new shared batch entry zone_pipeline::move_objects_simultaneously; the single-target and non-targeted-single paths use move_object. The stale 614.6 comment is deleted. Generalize the Phase C1 mill continuation rather than add a second parallel struct (CLAUDE.md parameterize-don't-proliferate applies to state types too): - PendingMillDeliveries -> PendingBatchDeliveries (identical { remaining, destination } shape; serialized as a plain struct so the type rename is wire-transparent; the GameState field pending_mill_deliveries -> pending_batch_deliveries carries a serde field-name alias for save compat). - New zone_pipeline::move_objects_simultaneously runs each request through move_object, parks + stashes the undelivered tail on a CR 616.1 pause, and stamps CR 603.10a co-departure over the departed subset on completion (a no-op for non-battlefield origins, so mill reuses it unchanged). - zone_pipeline::drain_pending_batch_deliveries replaces mill::drain_pending_mill_deliveries; mill::apply_mill_after_replacement now delegates to the shared batch entry, deleting its bespoke deliver_mill_cards. - engine_replacement.rs drains pending_batch_deliveries from both the Execute and Prevented resume arms. A single applicable redirect never prompts (the realistic path), so the common mass bounce never pauses. Only two simultaneous redirects on one object split a batch; the co-departure stamp is then per delivered segment rather than threaded across the pause boundary (no parsed card hits this — documented). Two discriminating tests (bounce_destination_redirect.rs): - mass_bounce_honors_to_hand_redirect: a single to-hand -> exile redirect sends every mass-bounced creature to EXILE, not the hand. FAILS on the old raw move. - mass_bounce_under_two_redirects_delivers_every_permanent_through_choices: two simultaneous redirects make every bounced creature prompt a CR 616.1 ordering choice; answering each delivers ALL of them (none stranded) and fully drains the parked batch tail. CR 614.6 (line 3072), CR 616.1 (line 3169), CR 603.10a (line 2634), CR 400.7 (line 1948). * fix(engine): centralize replacement-choice park in move_object so paused single moves can't ghost (round-3 review Fix 1+2) zone_pipeline::move_object did not park state.waiting_for on NeedsChoice: replace_event sets only pending_replacement, and the wait-state was each caller's to set. The C3/C4 single-move migrations (counter.rs x2, bounce.rs x3) bailed with 'return Ok(())' under comments falsely claiming the pipeline parks. Under two simultaneously-applicable graveyard->exile redirects (Rest in Peace + Leyline of the Void, or two RIP copies — RIP is not legendary), a countered spell was: removed from the stack, SpellCountered emitted, its move parked in pending_replacement, and the prompt NEVER surfaced (the engine gates ChooseReplacement on the wait state) — permanently ghosting the spell (off state.stack but obj.zone == Stack). Centralize the park at the single unparked origin: execute_zone_move's replace_event NeedsChoice arm now calls replacement::park_waiting_for before returning, making counter.rs, bounce.rs, seek.rs (latent same-class ghost — it discards the result entirely), and every future C5-C9 single-move migration safe by construction. Idempotence verified: - park_waiting_for keeps the CR 614.12a devour guard (never clobbers an already-surfaced EffectZoneChoice) and recomputes the identical ReplacementChoice from the same pending_replacement, so callers that still self-park (change_zone's park_waiting_for arms; end_phase / exile_from_top_until's replacement_choice_waiting_for) double-set the same value. - sba.rs's self-parks are on its OWN replace_event calls (Destroy / inner ZoneChange proposals), not downstream of move_object — unaffected today; redundant-but-idempotent when C7 migrates it. - deliver_batch's explicit park is removed (now redundant); the delivery-tail NeedsChoice path is deliberately NOT parked here — its wait state is already set by the counter-pause/devour machinery. The false 'parks via the pipeline' comments are now true and updated to credit the centralized park; two stale CR 608.2b citations corrected to CR 701.6a. Discriminating test countered_spell_under_two_redirects_surfaces_prompt_and_exiles (fail-first verified): counters a spell under RIP + Leyline, asserts the CR 616.1 ordering prompt SURFACES, answers it via a real GameAction::ChooseReplacement dispatch, and asserts the spell ends in EXILE with no ghost (zone == Exile, exile container holds it, graveyard empty, pending_replacement clear). Pre-fix run: FAIL with 'waiting_for = Priority { player: PlayerId(0) }, spell zone = Stack' — the exact unparked-pause ghost. CR 616.1 (line 3169), CR 614.6 (line 3072), CR 701.6a (line 3301). * fix(engine): batch-continuation comment accuracy + rad-counter park bail + discard delivery re-bucket (round-3 review Fix 3) (a) deliver_batch's aura-arm comment claimed a stale tail 'surfaces a bug' (undrained). It does not: the engine_replacement drain gate keys on pending_batch_deliveries.is_some(), not provenance, so a stale tail would be silently drained by the NEXT unrelated replacement-choice resume — the same mischaracterization the P2 review fix corrected in mill.rs. State the real behavior. (b) The mark_simultaneous_departures no-op-for-mill reasoning was wrong: departed_subset (zones.rs:609) DOES include milled cards — it filters on current zone != Battlefield, and a card now in a graveyard passes. The actual no-op comes from the EVENT gate: mark_simultaneous_departures (zones.rs:580) only stamps ZoneChanged events with from: Some(Zone::Battlefield), and a library-origin move emits none. Both comments corrected. (c) rad_counters now bails on a parked mill (apply_mill_after_replacement -> false) like mill::resolve does: continuing into the CR 728.1 life-loss loop would propose LifeLoss replacement events while the parked CR 616.1 choice is pending and could overwrite pending_replacement, ghosting the paused milled card. The life-loss/rad-removal tail is dropped on that parked path (reachable only with two simultaneous graveyard redirects active while rad counters trigger) — accepted and documented rather than threaded through the batch resume. (d) discard.rs complete_discard_to_graveyard re-bucketed with a comment: it is a Bucket A post-replacement DELIVERY site (eventual Phase E shape: approve_post_replacement + deliver on the lowered ZoneChange carrying applied), not a move_object site — re-proposing would double-apply Discard-level definitions. Deferred from this round because deliver's tail drains post_replacement_continuation at delivery time with Moved context, and timing equivalence for the Discard-execute chain class (madness / Abundance) needs its own discriminating tests. Audit correction recorded at the site: the discard_applier Discard->ZoneChange lowering runs only when a Discard-level definition (madness class) applies — moved_matcher accepts only ZoneChange proposals — so a PLAIN discard never consults Moved redirects and Rest in Peace does not exile it today. That graveyard-redirect gap (same class as mill C1 / counter C3 / bounce C4) needs the inner-ZoneChange-with-applied lowering plus a discriminating RIP-on-discard test as its own commit. CR 616.1 (line 3169), CR 728.1 (line 6239), CR 603.10a (line 2634), CR 701.9a (line 3334 area — see docs), CR 303.4f (line 1652). * fix(engine): plain discard consults Moved redirects (CR 614.6 / 701.9a) A plain discard moved hand -> graveyard via raw `move_to_zone`, never consulting `Moved` replacements, so Rest in Peace / Leyline of the Void did not exile a discarded card. Lower the accepted Discard into an inner hand -> graveyard `ZoneChange` carrying the outer pass's `applied` set and run it through the replacement pipeline, mirroring `discard_applier`'s lowering. The `applied` set guards against re-running Discard-level (madness) definitions; the lowered ZoneChange already re-loops through the pipeline (CR 616.1f) for the madness path, so only the unmodified Execute(Discard) arm needed the fix. - complete_discard_to_graveyard now takes (source_id, applied), re-proposes through replace_event, and returns DiscardOutcome so a Moved-redirect CR 616.1 choice can propagate. - Mayhem marker (CR 702.187b, record_card_discarded) stamped only when the card actually reaches the graveyard (CR 701.9c) — redirects leave it elsewhere, matching the Madness -> exile path. - All four callers (resolve specific/non-specific, discard_as_cost, handle_replacement_choice resume, ward-discard) updated to surface the pause. CR grep (docs/MagicCompRules.txt): 701.9a To discard a card, move it from its owner's hand to that player's graveyard. 701.9c If a card is discarded, but an effect causes it to be put into a hidden zone instead ... 614.6. If an event is replaced, it never happens. A modified event occurs instead ... Discriminating tests (effects/discard.rs): plain_discard_consults_rest_in_peace_and_exiles — RIP on board, plain discard ends in exile not graveyard (failed on the old raw-move path). madness_class_discard_still_works_without_double_consult — madness redirect to exile fires exactly once (no double-consult). * fix(engine): seek bail-on-pause + route non-battlefield move through pipeline (CR 616.1 / 614.6) Seek ignored `execute_zone_move`'s result and always pushed `EffectResolved`, so a per-card replacement pause was clobbered, and a multi-card seek with consecutive pausing replacements overwrote `pending_replacement` — ghosting earlier picks. It also moved non-battlefield destinations via raw `zones::move_to_zone`, never proposing a per-card ZoneChange (C9 entry), so `Moved` redirects never fired. Replace the per-card loop with the shared batch entry `zone_pipeline::move_objects_simultaneously` (mill::resolve pattern): both destinations now route through the pipeline, attribution stays `ability.source_id` (so battlefield entries record `entered_via_ability_source` and exile-link tracking keys off the seek source), and a mid-batch CR 616.1 pause parks `state.waiting_for` + stashes the tail in `pending_batch_deliveries`. Bail before `EffectResolved` on NeedsChoice so the prompt is not clobbered. CR grep (docs/MagicCompRules.txt): 614.6. If an event is replaced, it never happens. A modified event occurs instead ... (616.1 ordering among applicable replacements — pipeline_loop authority) Discriminating test (effects/seek.rs): seek_parks_on_per_card_replacement_choice_and_stashes_tail — two simultaneous graveyard->exile redirects make the first seeked-to-graveyard card prompt for CR 616.1 ordering; the batch parks, stashes the tail, and EffectResolved is NOT emitted (old loop ran to completion over the parked prompt). * fix(engine): reveal_until battlefield entry routes through zone pipeline (CR 614.1c / 306.5b) The kept-card battlefield entry used a raw `zones::move_to_zone`, skipping the CR 614.1c delivery tail — so a revealed planeswalker/battle entered with 0 loyalty/defense counters and was immediately put into the graveyard by CR 704.5i. Route the entry through `zone_pipeline::move_object`: the delivery tail seeds intrinsic enters-with counters, enters-with-counters statics, and applies the CR 614.1 tap-state from the seeded EntryMods. The previous manual `obj.tapped = true` is dropped (the tail does it — double- application check). The `enters_attacking` combat placement (CR 508.4) stays after delivery (not part of the zone tail). Bail on NeedsChoice / NeedsAuraAttachmentChoice (centralized park in move_object). The `move_to_library_position` rest-pile site (shuffle_to_bottom) is a library-placement SIBLING raw mover, DEFERRED to Phase D with a comment: move_object's placement arm is still a Phase-A stub that skips the replacement consult, and a library→bottom reposition has no Moved-redirect class to consult, so routing through it gains nothing now. CR grep (docs/MagicCompRules.txt): 306.5b A planeswalker has the intrinsic ability "This permanent enters with a ..." 310.4b A battle has the intrinsic ability "This permanent enters with a number ..." 614.1c Effects that read "[This permanent] enters with . . . ," ... 704.5i If a planeswalker has loyalty 0, it's put into its owner's graveyard. 508.4. If a creature is put onto the battlefield attacking, its controller ch... 303.4f If an Aura is entering the battlefield under a player's control by any... 616.1. If two or more replacement and/or prevention effects are attempting to... Discriminating test (effects/reveal_until.rs): reveal_until_planeswalker_enters_with_intrinsic_loyalty — a loyalty-4 planeswalker revealed to the battlefield enters with 4 loyalty counters (old raw path: 0 counters, dead by CR 704.5i). * fix(engine): resolution-choice battlefield entries route through zone pipeline (CR 614.1c) The RevealUntilKeptChoice-accept and DigChoice-kept handlers moved battlefield-entry cards via raw `zones::move_to_zone` + a manual `obj.tapped`, skipping the CR 614.1c delivery tail — so a kept/dug planeswalker or battle entered with 0 loyalty/defense and died to CR 704.5i. Route the battlefield branches through `zone_pipeline::move_object` (consistent with the synchronous reveal_until path migrated in C5): the tail seeds intrinsic enters-with counters and applies the CR 614.1 tap-state from the seeded EntryMods, so the manual tap is dropped. Bail on NeedsChoice / NeedsAuraAttachmentChoice (centralized park; realistically unreachable for the dig classes). `enters_attacking` (CR 508.4) combat placement stays post-delivery. DigChoice now binds `source_id` for CR 400.7 attribution (falls back to the moved object when None, matching the pre-pipeline raw move). Non-battlefield resolution-choice moves (manifest/surveil/dig rest to graveyard, route_rest_partition) are left raw this round — they sit inside multi-card loops with post-loop cleanup (revealed-marker clearing, continuation drain) where a per-card Moved-redirect pause cannot simply `return` without stranding the rest; migrating them needs the batch-entry + continuation restructure and is deferred. Library-placement sibling sites (move_to_library_position / move_to_library_at_index) stay deferred to Phase D. CR grep (docs/MagicCompRules.txt): 306.5b A planeswalker has the intrinsic ability "This permanent enters with a ..." 310.4b A battle has the intrinsic ability "This permanent enters with a number ..." 614.1c Effects that read "[This permanent] enters with . . . ," ... 704.5i If a planeswalker has loyalty 0, it's put into its owner's graveyard. 508.4. If a creature is put onto the battlefield attacking, its controller ch... 400.7. An object that moves from one zone to another becomes a new object wit... 616.1. If two or more replacement and/or prevention effects are attempting to... 303.4f If an Aura is entering the battlefield under a player's control by any... Discriminating test (effects/reveal_until.rs): reveal_until_kept_choice_planeswalker_enters_with_loyalty — drives the RevealUntilKeptChoice accept handler; a loyalty-5 planeswalker enters with 5 loyalty counters (old raw handler: 0, dead by CR 704.5i). * fix(engine): non-destroy SBA deaths consult Moved redirects (CR 704.5 / 614.6) The non-destroy state-based-action graveyard moves (zero toughness, zero loyalty, zero defense, legend-rule loser, unattached aura, battle without a protector, final-chapter Saga sacrifice) used a bare `zones::move_to_zone`, skipping the CR 614.6 replacement consult. These are "leaves the battlefield" / "dies" events (CR 603.6c + CR 700.4), so a `Moved` graveyard->exile redirect (Rest in Peace / Leyline of the Void) must apply — it did not. Add a shared `move_to_graveyard_via_pipeline` helper that routes each SBA-departing permanent through `zone_pipeline::move_object` with `ZoneChangeCause::StateBasedAction`. It returns `true` (and the caller bails) on a CR 616.1 ordering pause, mirroring the established `check_lethal_damage` regeneration-pause arm; the CR 704.3 fixpoint re-runs after the choice resolves and re-derives any undelivered SBA deaths, so bailing strands nothing. The self-parks at the destroy loop remain redundant-but-idempotent with the centralized park in move_object. CR grep (docs/MagicCompRules.txt): 704.5f If a creature has toughness 0 or less, it's put into its owner's graveyard. 704.5i If a planeswalker has loyalty 0, it's put into its owner's graveyard. 704.5j If two or more legendary permanents with the same name are controlled ... 704.5m If an Aura is attached to an illegal object or player, or is not attac... 704.5s If the number of lore counters on a Saga permanent ... 704.5v If a battle has defense 0 ... 704.5w If a battle has no player in the game designated as its protector ... 603.6c Leaves-the-battlefield abilities trigger when a permanent moves from t... 700.4. The term dies means "is put into a graveyard from the battlefield." 614.6. If an event is replaced, it never happens. A modified event occurs instead ... 616.1. If two or more replacement and/or prevention effects are attempting to... Discriminating test (sba.rs): sba_zero_toughness_death_consults_rest_in_peace_and_exiles — a zero-toughness creature with RIP on the battlefield is exiled, not put into the graveyard (old bare-move path: graveyard). * docs(engine): document C8/C9 zone-pipeline deferral — no Moved class targets Hand/Exile/Stack (PLAN Risk #5/#8) C8 (casting_costs/engine.rs cost sites) and C9 (Hand/Exile 1-site tail) are analyzed and deferred: the parser and synthesis only ever emit `Moved` redirects with `destination_zone` Graveyard (Rest in Peace / Leyline class) or Battlefield (ETB modifiers) — verified by grep of oracle_replacement.rs `destination_zone(Zone::...)`. There is NO `Moved` redirect class targeting a Hand, Exile, or Stack destination, so routing those moves through `move_object` would consult nothing and only add an unresumed-pause path to cost-payment / draw / dig flows that do not model a mid-flow replacement-choice resume. Draw-level replacements already apply at `ReplacementEvent::Draw` upstream. Records the finding inline at draw.rs:209 (the PLAN Risk #5 audit anchor), covering the whole Hand/Exile C9 tail (gift_delivery, connive, explore, turns return-to-hand; haunt, discover, exile_top, cascade, collect_evidence, ripple). seek.rs:97 and mill.rs:108 were migrated in earlier rounds (D2 / C1); discard.rs:38 stays Bucket A (Phase E). CR grep (docs/MagicCompRules.txt): 614.6. If an event is replaced, it never happens. A modified event occurs instead ... No behavioral change; comment-only. No discriminating test (the deferral is the absence of a behavioral change to test). * fix(engine): scope self-ETB Moved replacements to battlefield entry (CR 614.1c) Self-scoped as-enters replacements ("~ enters with N counters", enters tapped/prepared, as-enters choices, enter-as-copy, Karoo/shock/reveal lands; plus the Fading/Vanishing, Modular, Sunburst, Graft, Bloodthirst, Devour, Amplify keyword synthesizers) parsed/synthesized as ReplacementEvent::Moved with valid_card(SelfRef) and NO destination_zone. moved_matcher skips the destination gate when destination_zone is None, and a battlefield permanent is in-scan for its OWN departure — so the def matched the permanent's own battlefield EXIT: phantom counters + CounterAdded events on corpses (SBA deaths, bounce, destroy/sacrifice), a spurious CR 616.1 ordering prompt under a single Rest in Peace, and Optional clone defs forcing an "enter as a copy?" prompt on death. Fix is data-shape, not matcher special-casing: stamp .destination_zone(Zone::Battlefield) at construction on every self-ETB Moved def — CR 614.1c defs ("[This permanent] enters with...") are definitionally battlefield-entry-scoped. Parser sites: enters-tapped (unconditional/unless/if-controls), Karoo pay-cost, enters-prepared, reveal-land, shock land, as-enters-choose, clone, counter-choice, and the enters-with-counters builder (previously stamped only the is_external ChangeZone branch). Synthesis sites: Fading/Vanishing, Modular, Sunburst, Graft, Bloodthirst, Devour, Amplify (Riot/Unleash/Siege/Tribute/Saga were already stamped). The unearth / "would leave the battlefield" departure watchers stay destination-agnostic by design. CR grep (docs/MagicCompRules.txt): 614.1c Effects that read "[This permanent] enters with . . . ," "As [this per... 614.6. If an event is replaced, it never happens. A modified event occurs ins... 616.1. If two or more replacement and/or prevention effects are attempting to... Discriminating tests (fail-first evidence captured pre-fix): sba.rs::sba_death_does_not_apply_own_enters_with_counter_replacement — FAILED pre-fix: phantom counter on corpse (left: 1, right: 0) sba.rs::sba_death_under_single_rip_exiles_directly_no_prompt_no_counters — FAILED pre-fix: spurious CR 616.1 ordering prompt bounce.rs::bounce_does_not_apply_own_enters_with_counter_replacement — FAILED pre-fix: phantom counter on bounced card (left: 1, right: 0) All three drive parse_replacement_line (real parser output), then the SBA / bounce pipeline. * docs(engine): correct C8/C9 deferral rationale at draw.rs (destination-None Moved class) The recorded rationale ("no Moved class targets Hand/Exile") was incomplete: self-ETB Moved constructors carried NO destination_zone, and None matches every destination — so pre-Fix-1 such defs DID match Hand/Exile deliveries. The deferral conclusion stands (and was reinforced: migrating C8/C9 before the destination stamps landed would have widened the phantom-application defect). Post-stamp, explicit destinations are Graveyard/Battlefield only and the remaining destination-None defs are deliberate battlefield-departure watchers (unearth class), so the original claim is now true. Comment-only. * fix(engine): prevented discard records nothing; document paused-path bookkeeping gap (CR 614.6 / 701.9a) Prevented arm: a prevented inner ZoneChange means the card never left the hand — per CR 701.9a (to discard = move hand -> graveyard) NO discard occurred, so skip record_discard / the CR 702.187b Mayhem stamp / the Discarded event. Previously the arm fell through and recorded+emitted a discard that never happened, incoherent with the NeedsChoice early-return. Distinct from a REDIRECTED discard (CR 701.9c: still discarded — the Execute and madness arms keep recording+emitting). NeedsChoice arm: documented gap (counter.rs resolve_all style) instead of a resume-side continuation — a CR 616.1 ordering pause on the inner move (TWO materially-different Moved redirects on one discard, e.g. RIP + Wheel of Sun and Moon) delivers the card via the generic ZoneChange resume with no discard context, skipping "whenever you discard" bookkeeping for that card and abandoning a multi-card remainder. A proper fix needs a new serialized state slot + resume wiring; not built for a board no parsed deck assembles. Single-redirect boards (RIP alone) never prompt and are fully correct. CR grep (docs/MagicCompRules.txt): 614.6. If an event is replaced, it never happens. A modified event occurs ins... 701.9a To discard a card, move it from its owner's hand to that player's grav... 701.9c If a card is discarded, but an effect causes it to be put into a hidde... * fix(engine): batch-tail re-stash preserves request mods + attribution (CR 400.7 / 614.1c) PendingBatchDeliveries rebuilt paused-batch tails as ZoneMoveRequest::effect(obj, dest, obj), dropping the seek flow's enter_tapped mod and ability-source attribution across the pause boundary (seek is the first batch caller passing non-default EntryMods + a shared source). Extend the stash with serde-default fields (source_id, enter_tapped, exile_tracking) carrying the batch-uniform request context: captured from the first tail request in the new stash_batch_tail helper (source equal to the request's own object_id is the mill self-anchor idiom and stashes None), re-applied per request in drain_pending_batch_deliveries. Batch-uniform rather than per-request, mirroring the single-destination batch design; per-card heterogeneity remains a flagged design extension. Test: seek_parks_on_per_card_replacement_choice_and_stashes_tail extended to assert the stashed tail preserves the seek's ability-source attribution. * feat(engine): scan stack-resident object's own Moved redirect on stack exit (CR 608.2n / 614.12) find_applicable_replacements scanned only [Battlefield, Command] plus the entering-object (to:Battlefield) and discard exceptions, so a spell's own self-scoped `Moved` replacement was never discovered for its stack -> graveyard move. Add a stack-self-move exception mirroring the entering- object exception: when the proposed event is a ZoneChange whose `from` is Stack, include the moving object itself as a candidate source so its own SelfRef-scoped Moved def can fire as it leaves the stack. Hot-path cost: a single per-event Option match on the event's `object_id` when `from == Stack` (no extra zone sweep); the loop iterates the same `active_replacements` set as before, this only lets that one object pass the zone gate, and the `is_stack_self_move && !in_scanned_zone` SelfRef guard keeps it scoped to that object's own definitions. Inert until a stack -> graveyard self-redirect def is installed (next commit wires the Invoke Calamity rider through it): no parsed self-scoped Moved def today carries destination_zone: Graveyard, and the existing enters-with-counters self-defs are scoped to destination_zone: Battlefield. CR grep (docs/MagicCompRules.txt): 608.2n As the final part of an instant or sorcery spell's resolution, the spell is put into its owner's graveyard. 614.12. Some replacement effects modify how a permanent enters the battlefield. * fix(engine): route stack resolution defaults through pipeline; replace exile-rider flag with synthetic Moved def (CR 608.2n / 614.6) The stack resolution-default moves (resolved instant/sorcery → graveyard, fizzled/countered-on-resolution spell, prevented permanent → graveyard) delivered via raw `move_to_zone`, never proposing the inner ZoneChange, so board-wide `Moved` graveyard→exile redirects (Rest in Peace / Leyline of the Void) silently dropped on resolved/countered/prevented spells (PLAN §8 Risk #2 — confirmed bug). Route all three through `zone_pipeline::move_object` (new `ZoneMoveRequest::spell_resolution_default`, Cause::SpellResolutionDefault). The Invoke Calamity free-cast "if this spell would be put into your graveyard, exile it instead" rider was a bespoke per-object boolean (`exile_from_stack_instead_of_graveyard`) read by hand at the two dest computations. Replace it with a synthetic self-scoped `Moved` replacement (valid_card: SelfRef, destination_zone: Graveyard, execute: ChangeZone → Exile) installed at the same attach point — the same class as RIP/Leyline, just scoped to one spell — so the pipeline applies it like any other Moved redirect. Delete the flag, its game_object field, and the `object_exiles_instead_of_graveyard` reader. The flag may appear in saved games; GameObject has no deny_unknown_fields, so serde ignores the legacy field on deserialize (an old mid-cast save lacks the synthetic def — the def travels with the object via replacement_definitions; acceptable). Flashback/aftermath/harmonize exile-on-stack-exit stays a STATIC destination rule selected pre-pipeline (dest = Exile), so its proposed move is Stack→Exile; the Graveyard-scoped redirect class never matches it — no double-apply. CR 616.1 ordering pauses (two simultaneous redirects) are parked by move_object; each site emits the standard StackResolved + trigger-context-clear epilogue and bails for the replacement-choice resume. CR grep (docs/MagicCompRules.txt): 608.2n As the final part of an instant or sorcery spell's resolution, the spell is put into its owner's graveyard. 608.3e If a permanent spell resolves but its controller can't put it onto the battlefield, that player puts it into its owner's graveyard. 614.1a Effects that use the word "instead" are replacement effects. 614.6. If an event is replaced, it never happens. A modified event occurs instead. Discriminating tests: stack.rs::rest_in_peace_exiles_resolved_instant — RIP redirects a resolved instant's graveyard move to exile (fails on old raw delivery). stack.rs::flashback_spell_exiles_once_with_rest_in_peace_present — flashback exiles exactly once via its static rule; RIP (graveyard-scoped) does not double-apply on the stack→exile move. casting_costs.rs::invoke_calamity_rider_exiles_free_cast_spell_on_resolution — the rider's synthetic Moved def redirects a free-cast spell to exile on resolution through the deleted-flag's replacement path. * fix(engine): paused fizzle runs the resolution epilogue before bailing (CR 608.2b / 616.1) The fizzle/countered-on-resolution arm bailed on a replacement-ordering pause with a bare `return`, skipping the epilogue directly below it (StackResolved emission + current_trigger_event / current_trigger_events / current_trigger_match_count / die_result_this_resolution clears) — leaking stale cross-resolution context across the pause and never emitting StackResolved. The other two C2 sites (instant→graveyard else-branch, prevented-permanent arm) inline that epilogue before their pause-returns; the fizzle arm now falls through to its shared epilogue instead (the pause needs nothing else from the fizzle path — `move_object` parked the prompt and the move, and the resume path delivers it). Reachable: an Invoke Calamity free-cast spell fizzling under a single Rest in Peace = rider + RIP = two simultaneous graveyard→exile redirect candidates = CR 616.1 ordering prompt = this arm. Also documents the rider's known scope gap at the install site: the synthetic def carries no "this turn" duration (ReplacementDefinition has no duration field; revert_layered_characteristics_to_base only runs for battlefield exits), behavior-preserving vs the deleted flag — a Duration field on ReplacementDefinition is the eventual fix. CR grep (docs/MagicCompRules.txt): 608.2b If the spell or ability specifies targets, it checks whether the targets are still legal ... 616.1. If two or more replacement and/or prevention effects are attempting to modify the way an event affects an object or player ... Discriminating test (fail-first verified): casting_costs.rs::invoke_calamity_rider_fizzle_under_rip_parks_choice_with_clean_epilogue — free-cast spell with the rider fizzles under one RIP → CR 616.1 prompt parks → StackResolved emitted + all four context fields cleared on the paused path → GameAction::ChooseReplacement resumes → spell exiled. Pre-fix failure: panicked at "paused fizzle must still emit StackResolved" (prompt assertion passed, proving reachability). * fix(engine): route surveil + manifest-dread rest piles through zone pipeline (Phase C6) The non-battlefield rest-pile loops in engine_resolution_choices.rs delivered every unkept card with a bare zones::move_to_zone(.., Zone::Graveyard, ..), proposing no per-card ZoneChange — so each card's own Moved redirects (Rest in Peace / Leyline of the Void: 'would be put into a graveyard from anywhere -> exile instead') silently never fired (PLAN Risk #1 class). The post-loop cleanup (surveil kept-on-top reorder; manifest-dread reveal-marker removal) ran inline at the end of the loop, which is why C6 was deferred twice: a per-card CR 616.1 pause mid-pile would run that cleanup before the paused tail finished, then never again. Route both piles through a new zone_pipeline::move_objects_simultaneously_then, which carries a typed BatchCompletion (NOT a closure) describing the post-loop work. The batch runs the cleanup exactly once on true completion: inline on the synchronous (single-redirect) path, and via drain_pending_batch_deliveries when a CR 616.1 ordering choice pauses the pile. BatchCompletion rides on the parked PendingBatchDeliveries tail (mirroring PendingCounterPostAction), and the drain re-attaches it across each re-park so it can never run early or twice. The paused-on-last-card empty-tail case is handled by ensure_batch_record so the completion still fires after the final card's redirect resolves. CR grep (docs/MagicCompRules.txt): 701.25a To 'surveil N' means to look at the top N cards of your library, then put any number of them into your graveyard and the rest on top of your library in any order. 614.6. If an event is replaced, it never happens. A modified event occurs instead ... 616.1. If two or more replacement and/or prevention effects are attempting to modify ... 603.10a Some zone-change triggers look back in time. These are leaves-the-battlefield abilities ... Discriminating tests (surveil_rest_pile_redirect_continuation.rs): surveil_rest_pile_honors_graveyard_exile_redirect — single redirect: unkept cards land in EXILE (not graveyard), kept card stays on top once. FAILS on the old raw move (cards reached the graveyard). surveil_rest_pile_under_two_redirects_runs_keep_on_top_cleanup_once — two simultaneous redirects pause every unkept card on a CR 616.1 prompt; answering each delivers ALL to exile (none stranded), drains the parked tail, and runs the kept-on-top reorder EXACTLY once (kept card on top, present once — not duplicated, not missing). * refactor(engine): route exempt-cause moves through zone pipeline (Phase D) Migrate the four CR-exempt zone-change classes off raw zones::move_to_zone onto the single pipeline entry under explicit exempt ZoneChangeCause variants, so Phase E can flip enforcement knowing every production caller goes through the pipeline. The exempt path seals the proposed event directly and delivers it WITHOUT the replace_event consult (the 'would'-semantics layer); the unconditional primitive guards (CR 111.8 token, CR 614.1d ETB block, CR 400.7 cleanup) still run in zones.rs delivery for every cause (PLAN section 2/3). Sites: - mulligan.rs (CR 103.5 PregameProcedure): Serum Powder hand->exile, mulligan hand->library shuffle, pregame draw, and the two opening-hand/mulligan bottoming loops (via the library-placement arm). - elimination.rs (CR 800.4a PlayerLeftGame): owner's objects -> exile. 'This is not a state-based action' and no replacement applies to a player leaving. - casting_costs.rs::finalize_cast (CR 601.2a CastingToStack): the committed Hand/GY/Exile/Command -> Stack transition. Part of the casting process, not a replaceable event. - engine_debug.rs (DebugCommand): MoveToZone (incl. library top/bottom/Nth via placement), Mill, and the no-ETB battlefield staging path. Operator intent is 'force the state'; no intrinsic enters-with-counter seeding, matching the prior raw placement. ZoneChangeCause::is_exempt() is the single authority for the consult skip; each exempt arm carries its CR citation. New ZoneMoveRequest constructors (casting_to_stack / pregame / player_left_game / debug) keep call sites short. Removed now-unused 'use super::zones' in mulligan.rs. CR grep (docs/MagicCompRules.txt): 103.5. Each player draws a number of cards equal to their starting hand size ... A player who is dissatisfied with their initial hand may take a mulligan ... shuffles the cards in their hand back into their library ... puts a number of those cards ... on the bottom of their library 601.2a To propose the casting of a spell, a player first moves that card (or that copy of a card) from where it is to the stack. 800.4a When a player leaves the game, all objects (see rule 109) owned by that player leave the game ... This is not a state-based action. No behavior change: exempt causes already bypassed the consult via raw moves; this routes them through the pipeline with identical delivery. Existing mulligan / elimination / debug / casting tests stay green (test-engine ok). * docs(engine): document W3 library-placement consult deferral with evidence (Phase D) The placement-aware pipeline arm exists as a stub (move_object delivers a Some(placement) request directly via move_to_library_at_index, skipping the replacement consult). Phase D's exempt pregame/debug library callers already route through it. Completing the stub so it RUNS the consult on a library placement (PLAN section 3.5 'the consult should still run for future-proofing') is DEFERRED with evidence: - Zero value today: no Moved replacement in the card pool targets destination_zone(Library). Verified destination distribution: 25 destination_zone(Zone::Battlefield) 17 destination_zone(Zone::Graveyard) 2 destination_zone(Zone::Exile) 0 Library so the consult is a guaranteed no-op on every library placement. - Real risk for that no value: a correct completion must gate the CR 701.24a delivery-tail auto-shuffle on placement-absence across the shared deliver / deliver_replaced_zone_change / DeliveryCtx signatures (a library *placement* must NOT shuffle; a plain library-destination ZoneChange MUST). That is a cross-cutting change to every bucket-A/B/C delivery caller with a silent-randomization landmine (a wrong gate randomizes put-on-top / scry / cascade placements with no crash or test failure). The raw move_to_library_position / _at_index sibling production callers (put_on_top, cascade, discover, reveal_until, drawn_this_turn_choice, engine_resolution_choices) stay on the raw movers until that completion: a library reposition is not 'put into a graveyard/exile/hand', so nothing is skipped by staying raw. Migrating them onto the stub arm ahead of the consult completion is value-neutral churn that does not advance Phase E (which can only demote the library movers once ALL callers AND the consult are in place) and carries per-site top-order-reversal preservat…
matthewevans
added a commit
that referenced
this pull request
Jun 12, 2026
…e-safe battlefield entries (phase-rs#2829) * fix(engine): route declined-cipher, declined-madness, and legend-rule losers to graveyard through zone pipeline (CR 608.2n / 702.35a / 704.5j / 614.6) These three graveyard-destination moves delivered via raw move_to_zone, never proposing the inner ZoneChange, so board-wide Moved graveyard->exile redirects (Rest in Peace / Leyline of the Void) silently dropped: - cipher decline (CR 608.2n): the resolving cipher card -> owner's graveyard now routes through SpellResolutionDefault; handle_encode_choice returns the ZoneMoveResult so the caller surfaces a parked CR 616.1 prompt instead of clobbering it with Priority. - madness decline (CR 702.35a): the exiled card -> owner's graveyard now routes through move_object; the arm evaluates to the parked WaitingFor on pause so the post-action pipeline is skipped. - legend rule (CR 704.5j): the losing legends -> graveyards now route through move_objects_simultaneously (CR 603.10a co-departure stamp); a mid-batch CR 616.1 choice parks the prompt and stashes the tail. CR grep (docs/MagicCompRules.txt): 608.2n spell goes to graveyard on resolution; 702.35a madness; 704.5j legend rule; 614.6 replaced event never happens; 603.10a simultaneous leaves-battlefield. * fix(engine): route Attraction-open and ninjutsu battlefield entries through zone pipeline (CR 614.1c) Both battlefield entries delivered via raw move_to_zone, skipping the pipeline delivery tail that applies enters-with-counters statics (StaticMode::EntersWithAdditionalCounters — Hardened Scales / Conclave Mentor 'creatures you control enter with an additional +1/+1 counter' class). The raw mover applies those statics nowhere, so an opened Attraction or a ninja entering via ninjutsu silently missed them. Route both through zone_pipeline::move_object. A battlefield-entry pause (CR 616.1 multi-redirect ordering / CR 303.4f aura host / counter- replacement) is not reachable for these object classes in the supported pool: an Attraction is a non-Aura artifact, a ninja is a non-Aura creature, and no supported entry surfaces a choice. The bail is a safety guard, not a resume path — ninjutsu's post-entry combat placement (CR 702.49c) cannot resume across a pause, so on the unreachable pause we stop with the prompt parked rather than act over parked state. Attribution self-anchors (CR 400.7; the raw move recorded no source). CR grep (docs/MagicCompRules.txt): 614.1c enters-with statics; 702.49c ninjutsu combat placement; 616.1 multi-replacement ordering. * docs(engine): correct stale aura-arm drain note + document dig multi-kept pause limitation Two comment-only corrections from the d5a12b8c6 review verdict: 1. zone_pipeline.rs deliver_batch aura arm: the note claiming a tail stashed on NeedsAuraAttachmentChoice would be 'silently drained by the NEXT unrelated replacement-choice resume' is stale. As of d5a12b8c6 the ReturnAsAuraTarget handler (engine.rs:3608-3611) and its chain-resume sibling (engine.rs:3572) both drain pending_batch_deliveries, so the aura-attachment resume finishes the parked batch correctly. 2. engine_resolution_choices.rs DigChoice kept-loop pause: document the multi-kept limitation — if kept card #1 pauses, kept phase-rs#2+ stay in the library (the for-loop return exits before they move), and publish_tracked_set: Some(kept) publishes ALL kept including the unmoved ones, so a downstream sub-ability can be wired to cards still in the library. Pre-existing, strictly no-worse-than the old raw path; revisit with a kept-loop continuation if a 2+-kept-to-battlefield dig that pauses on the first is ever added. No behavior change. * fix(engine): route reveal-until and dig graveyard rest piles through zone pipeline so Moved redirects fire (CR 614.6 / 701.20a / 603.10a) The reveal-until and dig 'put the rest into your graveyard' piles delivered via raw move_to_zone, never proposing the inner ZoneChange, so a board-wide Moved graveyard->exile redirect (Rest in Peace / Leyline of the Void) silently dropped on the rest cards. 12 card-data cards carry RevealUntil with rest_destination: Graveyard (Mind Funeral class), plus dig's no-kept-zone 'rest to graveyard' branch — all affected. move_rest is now the single authority: a Graveyard (or any non-library) rest pile routes through move_objects_simultaneously (CR 603.10a co-departure stamp), so each rest card consults the redirect; a Library rest pile keeps the random-order shuffle_to_bottom (no Moved-redirect class targets Library, and the placement arm would lose the shuffle). The new move_rest_then carries an optional BatchCompletion for callers that defer cleanup across a pause. Synchronous completion runs the resolver's own marker-clear + EffectResolved inline (the dispatching chain processor still owns priority/continuation). On a mid-pile CR 616.1 ordering pause, the prompt is parked and the cleanup is deferred onto a cleanup-only RevealRestPile completion (empty rest_cards — the pile IS the batch) so the drain runs it once and EffectResolved never lands over the parked prompt. The dig unkept->graveyard branch mirrors this, deferring finish_with_continuation via the same completion. Discriminating test reveal_until_graveyard_rest_redirected_to_exile_by_rest_in_peace: a graveyard-rest reveal-until with a RIP graveyard->exile Moved redirect on the battlefield now exiles the rest pile (old raw path: graveyard'd). CR grep (docs/MagicCompRules.txt): 614.6 replaced event never happens; 701.20a reveal until / rest pile; 603.10a simultaneous leaves-battlefield. * fix(engine): route morph and manifest face-down entries through zone pipeline (CR 708.3 / 614.1c) Both face-down battlefield entries (cast face-down via morph/disguise, and manifest) delivered via raw move_to_zone followed by a manual apply_face_down_creature_characteristics + back_face snapshot — bypassing the pipeline delivery tail, so a face-down 2/2 never received enters-with-counters statics ('creatures you control enter with an additional +1/+1 counter' — Hardened Scales / Conclave Mentor class). Route both through zone_pipeline::move_object with the new ZoneMoveRequest::face_down(profile) mod. The delivery tail is already the canonical face-down authority (it snapshots the real face into back_face and applies the vanilla-2/2 profile BEFORE the entry per CR 708.3, identical to change_zone's face-down path) AND seeds enters-with-counters statics, so the manual post-move override is dropped — morph/manifest now match the rest of the engine's face-down entries. A battlefield-entry pause is unreachable for a vanilla 2/2 (not an Aura, no Moved redirect / counter-replacement choice); the bail keeps the helpers safe by construction. CR grep (docs/MagicCompRules.txt): 708.2a face-down characteristics; 708.3 turned face down before it enters; 614.1c enters-with statics; 701.40a manifest. * fix(engine): route exile-until-leaves returns through zone pipeline (CR 610.3a / 614.1c) check_exile_returns (Banisher Priest / Fiend Hunter / Oblivion Ring class — 'exile until ~ leaves') returned each exiled card via raw move_to_zone, skipping the pipeline delivery tail: a battlefield return missed enters-with- counters statics (Hardened Scales class), and a non-battlefield return dropped any Moved redirect. Group the returns by destination zone (first-seen order for determinism — Zone isn't Ord) and route each group through move_objects_simultaneously_then (CR 603.10a co-departure). A returned creature can pause on an as-enters / aura-host choice (CR 303.4f / 616.1), so the spent UntilSourceLeaves link cleanup rides a new BatchCompletion::RemoveExileLinks per group, drained once the group's pile lands (synchronously or via the replacement-choice / aura-attachment resume) — never before a paused card finished returning. Links for cards that already left exile by other means are dropped immediately; only the in-flight group ids ride their completion. CR grep (docs/MagicCompRules.txt): 610.3a return to previous zone; 614.1c enters-with statics; 603.10a simultaneous; 303.4f aura host on entry. * fix(engine): route reveal-until kept-to-graveyard cards through zone pipeline; document dig rest-partition gap (CR 614.6 / 701.20a) A reveal-until KEPT card sent to the graveyard (4 cards, kept_destination: Graveyard — Mind Funeral-style 'put it into your graveyard') was delivered raw, skipping a Moved graveyard->exile redirect (Rest in Peace / Leyline of the Void). Route it through move_object on both the synchronous resolve path (the non-Hand/Battlefield kept branch; Library stays raw as a placement) and the RevealUntilKeptChoice handler (accept_zone / decline_zone via the new route_kept_card_or_defer helper). On a CR 616.1 pause the rest-pile move + marker clear defer onto a RevealRestPile completion; the kept-choice handler's rest pile now flows through move_rest_then so its completion (marker clear + finish_with_continuation) runs once on either path — closing the latent unhandled-pause the move_rest migration introduced here. Documents the remaining route_rest_partition gap (dig 'rest into graveyard', 65 Dig cards + the null->Graveyard default): it has a caller INSIDE run_batch_completion, so migrating it needs a re-pause-from-completion contract plus pause handling in both synchronous callers — a cross-cutting change tracked for a follow-up rather than a partial migration. CR grep (docs/MagicCompRules.txt): 614.6 replaced event never happens; 701.20a reveal until / rest pile. * fix(engine): paused face-down entry resumes face down; prevented-ETB fallback consults Moved redirects (CR 708.3 / 608.3e / 614.6) Round-1 review findings 1 (HIGH) + 3 (MEDIUM) on the zone-pipeline tail migration — both in handle_replacement_choice: 1. The ZoneChange resume arm destructured the approved event with '..', DISCARDING face_down_profile, and delivered via the raw mover — a morph / manifest entry parked on a CR 616.1 ordering prompt resumed FACE UP, violating CR 708.3 and leaking the morpher's hidden card. The prompt is REACHABLE: two co-played external enter-tapped Moved effects (Authority of the Consuls + Imposing Sovereign class) collide on the entry's tap field (no same-value dedupe), surfacing the ordering choice — empirically proven by the new test's parked-prompt assertion. Fix: extract the tail's CR 708.3 block into zone_pipeline::apply_face_down_entry_profile (single authority, not a mirrored copy) and call it from the resume arm immediately after the move, before the tap/controller/counter blocks (tail ordering). Full tail-routing via approve_post_replacement + deliver was assessed and deferred to the Phase-B token migration with a flagged TODO: the arm's epilogue drains post_replacement_continuation with spell-resolution ctx + post_replacement_source clearing, orders pending_spell_resolution differently, and carries the bespoke played_from_zone preservation (PLAN Open Question phase-rs#3) — three behavioral divergences that need their own reconciliation, not a drive-by. Morph/manifest 'pause unreachable' comments replaced with honest reachability documentation (the bail is complete: the profile rides the parked event; the resume applies it). 2. The CR 608.3e prevented-ETB graveyard fallback delivered raw. The consulted (prevented) event was the battlefield ENTRY; the fallback is a fresh, never-consulted event, so routing it through move_object (SpellResolutionDefault, mirroring stack.rs's C2 prevented-permanent site) cannot double-apply — the prevention def is Battlefield-scoped and cannot re-match a Graveyard move. RIP/Leyline redirects now fire on the discarded spell. The dead pending_continuation is cleared before the move so a CR 616.1 pause cannot leave it for the next resume's epilogue. Fail-first evidence (both red before the fix, green after): - paused_face_down_morph_entry_resumes_face_down: panicked 'resumed morph entry must be FACE DOWN (CR 708.3)' - prevented_etb_graveyard_fallback_consults_moved_redirects: left: Graveyard, right: Exile (staging note in-test: no ZoneChange applier can yield Prevented, so the parked choice is staged as a regeneration-shield Destroy prevention; the resume is driven through the real GameAction entry) CR grep (docs/MagicCompRules.txt): 708.2a face-down characteristics; 708.3 turned face down before it enters; 608.3e prevented ETB to graveyard; 614.6 replaced event never happens; 616.1 multiple replacement ordering. * fix(engine): ninjutsu and Attraction-open pauses resume via continuations instead of dropping post-entry work (CR 702.49 / 701.51 / 616.1) Round-1 review finding 2 (MEDIUM): both 3d1497411 bails were wrong because the battlefield-entry prompt IS reachable — two co-played external enter-tapped Moved effects (Authority of the Consuls + Imposing Sovereign for creatures; Kismet / Frozen Aether class for artifacts) produce a material same-field collision on the entry's tap state (no same-value dedupe) and surface a CR 616.1 ordering prompt. On that board: - ninjutsu: the bail skipped the cast-variant provenance tag AND the CR 702.49c tapped-and-attacking combat placement — the resumed ninja entered untagged and non-attacking. - Attraction open: the bail left in_attraction_deck set, never emitted AttractionOpened, and dropped every remaining open of the instruction. Adjudication: continuations, not documented limitation — the BatchCompletion infrastructure already exists (RevealRestPile / RemoveExileLinks shape). Two new variants: NinjutsuPlacement (defers finish_ninjutsu_entry: tag + combat placement + NinjutsuActivated + layers) and AttractionOpenRemainder (defers finish_attraction_open + the remaining opens, which may themselves re-park and re-defer through the same completion — the drain takes the old record before running it, so a fresh park is preserved). Both finish helpers are shared single authorities between the synchronous and resumed paths. The 'pause unreachable' comments are replaced with honest reachability documentation. Bonus CR-correctness in the shared helper: AttractionOpened now fires only when the card actually entered the battlefield (CR 701.51c — prevented or replaced entries must not trigger 'opens an Attraction'; Done also covers prevented/redirected deliveries). Also corrects the CR 610.3a cite to CR 610.3 proper on the RemoveExileLinks doc + completion arm (610.3a is the already-occurred-event timing subpart; 610.3 is the return rule). Fail-first evidence (both red before, green after): - paused_ninjutsu_entry_resumes_with_combat_placement_and_tag: panicked 'resumed ninja must be placed attacking (CR 702.49c)' - paused_attraction_open_resumes_bookkeeping_and_remaining_opens: panicked 'open bookkeeping must run on the resumed Attraction (old bail left the flag set)' Both tests passed their parked-prompt assertions pre-fix, empirically confirming the reviewer's reachability claim for both object classes. CR grep (docs/MagicCompRules.txt): 702.49/49a/49c ninjutsu + placement; 701.51/51b/51c open an Attraction + trigger gate; 616.1 ordering; 610.3 return-to-previous-zone. * docs(engine): flag CR 614.12 face-down consult gap; correct exile-return cite to CR 610.3 (review finding 4 + nit) Round-1 review finding 4 (MEDIUM-LOW, comment-only): execute_zone_move's replacement consult runs the matcher pass against the object's PRINTED characteristics, but CR 614.12 (docs/MagicCompRules.txt:3094) requires checking 'the characteristics of the permanent as it would exist on the battlefield' — for a face-down (morph/manifest) entry that is the 2/2 with no name/types/subtypes (CR 708.2a), so a type- or name-keyed entry replacement wrongly matches a face-down printed Wizard. Narrow class today (the common enter-tapped/counter statics are type-agnostic or creature-scoped, which the face-down 2/2 satisfies); the fix is profile-projected characteristics in the matcher pass when face_down_profile is present. Documented at the consult with the CR cite. Also corrects check_exile_returns' CR 610.3a cite to CR 610.3 proper (610.3a is the already-occurred-event timing subpart; 610.3 is the rule that creates the return one-shot). CR grep: 614.12 'check the characteristics of the permanent as it would exist on the battlefield'; 610.3 'A second one-shot effect ... returns the object to its previous zone.' No behavior change. * fix(engine): arrival-gate ninjutsu post-entry work, twin of the Attraction CR 701.51c gate (CR 702.49c) Round-2 review LOW: finish_ninjutsu_entry ran the cast-variant tag and the CR 702.49c combat placement unconditionally — ZoneMoveResult::Done also covers prevented/redirected deliveries, so a redirected resumed entry would tag a non-battlefield object and place it into combat.attackers. Gate both behind the same zone == Battlefield arrival check as finish_attraction_open. Unreachable today (no supported Moved redirect retargets a battlefield entry away from the battlefield), but the gate makes the helper correct by construction rather than by census, matching the twin's standard. NinjutsuActivated stays deliberately OUTSIDE the gate, unlike the Attraction twin's AttractionOpened: CR 701.51c explicitly suppresses the 'opens an Attraction' trigger when the entry is prevented/replaced, but ninjutsu's activation event occurred when the ability was activated (cost paid, attacker returned) — a redirected entry does not un-activate it. Asymmetry documented inline. No new test per the review (unreachable class, identical behavior for every reachable entry — existing paused_ninjutsu_entry_resumes_with_combat_placement _and_tag still green). CR grep (docs/MagicCompRules.txt): 702.49c enters attacking; 701.51c trigger suppression on prevented/replaced entry.
ntindle
added a commit
that referenced
this pull request
Jun 27, 2026
…and-play frequency, text/lower desync, context restore Maintainer @matthewevans raised four issues on PR phase-rs#4341 (The Fourth Doctor parser cluster). All four are addressed here. 1. Reflexive trigger provenance (issue #1): Replace the rules-incorrect `TriggerMode::PlayCard` trigger with a `TriggerMode::Unknown` gap marker. A global PlayCard trigger cannot distinguish which permission authorized a given play (CR 603.12), so the "When you do" rider was firing even when a different top-of-library permission authorized the play. The Unknown trigger keeps the gap visible in coverage until the casting/land-play pipeline gains permission-provenance tracking. 2. Land-play frequency consumption (issue phase-rs#2): Add `record_top_of_library_land_permission` in engine.rs and call it from all three completion arms of `handle_play_land`. A `OncePerTurn` `TopOfLibraryCastPermission { play_mode: Play }` now consumes the per-turn slot on land plays, mirroring how `finalize_cast` records this for spell casts (CR 401.5 + CR 601.2a). 3. Context mutation without restore (issue phase-rs#3 / Gemini): The reflexive rider is no longer parsed at all (Unknown trigger is emitted unconditionally), eliminating the ctx.subject/ctx.actor mutation that was leaking into subsequent lines. 4. text/lower desync on frequency-prefix strip (issue phase-rs#4 / Gemini): In `try_parse_top_of_library_cast_permission`, shadow both `text` and `lower` together when stripping the "once each turn, " / "once during each of your turns, " prefix, so downstream helpers receive aligned slices (Gemini phase-rs#3 suggestion, CR 601.2a). 5. Redundant scan_contains pre-check removed (Gemini phase-rs#2): The `scan_contains` guard before `split_once_on_lower` in oracle.rs was redundant — `try_parse_top_of_library_cast_permission` already validates the anchor. Removed for clarity. Test: `the_fourth_doctor_full_card_parse` updated to expect `TriggerMode::Unknown` instead of `TriggerMode::PlayCard`. Verification: cargo clippy -p engine --all-targets -- -D warnings: clean. cargo test -p engine: all pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR
matthewevans
added a commit
that referenced
this pull request
Jul 4, 2026
… — play historic from TOP OF LI (phase-rs#4341) * fix(parser): UNSUPPORTED cluster: The Fourth Doctor (Blast COMMANDER) — play historic from TOP OF LI * fix(PR-4341): address maintainer review — honest unsupported rider, land-play frequency, text/lower desync, context restore Maintainer @matthewevans raised four issues on PR phase-rs#4341 (The Fourth Doctor parser cluster). All four are addressed here. 1. Reflexive trigger provenance (issue #1): Replace the rules-incorrect `TriggerMode::PlayCard` trigger with a `TriggerMode::Unknown` gap marker. A global PlayCard trigger cannot distinguish which permission authorized a given play (CR 603.12), so the "When you do" rider was firing even when a different top-of-library permission authorized the play. The Unknown trigger keeps the gap visible in coverage until the casting/land-play pipeline gains permission-provenance tracking. 2. Land-play frequency consumption (issue phase-rs#2): Add `record_top_of_library_land_permission` in engine.rs and call it from all three completion arms of `handle_play_land`. A `OncePerTurn` `TopOfLibraryCastPermission { play_mode: Play }` now consumes the per-turn slot on land plays, mirroring how `finalize_cast` records this for spell casts (CR 401.5 + CR 601.2a). 3. Context mutation without restore (issue phase-rs#3 / Gemini): The reflexive rider is no longer parsed at all (Unknown trigger is emitted unconditionally), eliminating the ctx.subject/ctx.actor mutation that was leaking into subsequent lines. 4. text/lower desync on frequency-prefix strip (issue phase-rs#4 / Gemini): In `try_parse_top_of_library_cast_permission`, shadow both `text` and `lower` together when stripping the "once each turn, " / "once during each of your turns, " prefix, so downstream helpers receive aligned slices (Gemini phase-rs#3 suggestion, CR 601.2a). 5. Redundant scan_contains pre-check removed (Gemini phase-rs#2): The `scan_contains` guard before `split_once_on_lower` in oracle.rs was redundant — `try_parse_top_of_library_cast_permission` already validates the anchor. Removed for clarity. Test: `the_fourth_doctor_full_card_parse` updated to expect `TriggerMode::Unknown` instead of `TriggerMode::PlayCard`. Verification: cargo clippy -p engine --all-targets -- -D warnings: clean. cargo test -p engine: all pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR * fix(PR-4341): capture top-of-library land permission before zone change The `record_top_of_library_land_permission` helper was called in the land-play epilogue — after `zone_pipeline::deliver` had already moved the land from Library → Battlefield. At that point `top_of_library_permission_source` reads `player.library.front()`, which now points to the *next* card, so `top_id != object_id` always triggered and the slot was silently left unconsumed. A `OncePerTurn` top-of-library `Play` permission (The Fourth Doctor shape) could therefore be reused indefinitely for lands. Fix: capture `(src_id, frequency)` from `top_of_library_permission_source` *before* the replacement pipeline, alongside the existing `in_library_with_permission` eligibility check. The simplified `record_top_of_library_land_permission` now accepts the pre-captured pair instead of re-deriving it post-delivery. All three epilogue paths (Execute + `NeedsChoice` replacement prompt, `NeedsChoice` directly, and the normal path) use the captured value. Also adds `once_per_turn_library_land_play_consumes_slot_and_blocks_second_play`, a production-path regression test that plays two historic lands from the top of the library under a `OncePerTurn` `play_mode: Play` permission, asserts the first play stamps `top_of_library_cast_permissions_used`, and asserts the second play is rejected — the discriminating assertion that the pre-capture bug made impossible to satisfy. Addresses matt's round-4 review blocker (2026-06-28). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR * fix(CR-annotations): replace CR 601.2a with CR 305.1/116.2a/401.5 on land-play paths Matt's 2026-07-01 review (CHANGES_REQUESTED) identified that all six CR 601.2a annotations in the top-of-library land-play path cite the spell-casting-to-stack procedure (601.2a) instead of the rules that actually govern this path: - CR 305.1: playing a land is a special action, not a spell cast - CR 116.2a: playing a land puts the card onto the battlefield from the zone it was in (zero stack involvement) - CR 401.5: top-of-library visibility closes after the special action Fixed locations: - engine.rs: doc-comment on record_top_of_library_land_permission (×1) - engine.rs: inline comment in handle_play_land pre-capture block (×1) - engine.rs: three call-sites in the success/NeedsChoice/fallthrough branches of handle_play_land that record the permission slot (×3) - engine_tests.rs: doc-comment on the once-per-turn slot regression test (×1) All six now cite CR 305.1 + CR 116.2a + CR 401.5 with explanatory text. The two remaining mentions of CR 601.2a in the updated comments are explicit "does not apply here" notes, which is the correct documentation. Verification: cargo clippy -p engine --all-targets -- -D warnings (clean), cargo test -p engine (all pass), cargo fmt --all (no changes). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
matthewevans
added a commit
that referenced
this pull request
Jul 6, 2026
…phase-rs#5155) * feat(engine): dynamic keep count on the dig pipeline (Stargaze) Parameterize PutCount::Up/Exactly payload u32 -> QuantityExpr and add an additive Effect::Dig.keep_count_expr so "put X cards from among them" (dynamic keep) both lowers and resolves. Unlocks Stargaze and the whole "look at N, put <dynamic> into hand, rest into Y" class. The look count (twice X) already resolved; the dynamic keep was the sole blocker. Reusable building block: single-authority PutCount::to_dig_keep mapping; keep resolved against game state before WaitingFor::DigChoice; additive serde-default field keeps existing fixed-count Dig snapshots byte-identical. CR 701.20e (look), CR 608.2c (follow instructions), CR 107.1b (negative -> 0). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): opponent-constrained target-player slot (Quick Draw) Add ControllerRef::TargetOpponent -- a pure routing tag whose runtime read is identical to TargetPlayer but whose companion target-player slot offers only opponents (self excluded; any one opponent in >2p), reusing the existing TargetFilter::Typed{controller:Opponent} + find_legal_targets legality path. Lowers "creatures target opponent controls lose <kw>..." (Quick Draw) and unlocks the whole "target opponent controls" class. Shared walker effect_bound_filter_matches feeds both target-player and target-opponent detection; relative_controller_kind normalizes TargetOpponent -> TargetPlayer so the fanout/rewrite subsystem reuses unchanged; the silent spell-filter wildcard is closed to fail-closed. CR 109.4, CR 102.2 / CR 102.3 (opponent), CR 611.2c (EOT set fixed at start), CR 702.7a (first strike), CR 702.4a (double strike). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): reflexive "this way" delayed-trigger building block (Prishe, Rhino) Add DelayedTriggerLifetime::Reflexive (CR 603.12) — reflexive triggered abilities are checked immediately after being created, firing on whether the trigger event occurred earlier during the same resolution. Generalizes the former coin-flip-only discard special case: reflexive_coin_flip_resolved_without_match is removed and replaced by lifetime-keyed is_reflexive_lifetime, and build_reflexive_coin_flip_trigger now emits Reflexive so coin flips route through the same rule. Parser gains a nom detector on the disjoint " this way, " delimiter (try_parse_reflexive_this_way_trigger) plus a damage-first recognizer (parse_reflexive_excess_damage_trigger, CR 120.10 excess-damage). Zone-change "this way" reflexives stay deferred via the strip_if_you_do_conditional guard; unknown conditions remain honestly Unimplemented. Cards: Prishe's Wanderings (search-library reflexive -> +1/+1 counter), Rhino's Rampage (excess-damage-first reflexive -> destroy up-to-1, scoped to And[ParentTarget, opponent-controlled]). Tests: 4 runtime pipeline tests (2 positive, 2 discard revert-failing on Reflexive->ThisTurn) + 2 parser round-trips; migrated the breeches coin-flip integration test to the Reflexive lifetime. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): scope "lose control" trigger + emit control-loss at cleanup (CR 514.3a) match_changes_controller fired on ANY ControllerChanged/EffectResolved{GainControl}, ignoring valid_card and direction — a latent over-fire (Portent trap) for the three supported "When you lose control of ~" cards (Khârn the Betrayer, Duplicity, Gustha's Scepter). Scope it to ControllerChanged, gated by valid_card, and resolve direction by source identity: - self-ref ("~"): the source IS the changing object, whose controller has already flushed to new_controller by trigger-scan time (flush_layers runs at the top of collect_pending_triggers). Rely on CR 603.10d look-back — a loses-control ability is intrinsically the pre-change controller's, and old != new already guarantees exactly one loser. Fire for it. - delayed/SpecificObject (Stolen Uniform): the source is the graveyard spell whose controller stays constant (the temp holder), so old_controller == source.controller fires on the loss (old == caster) and not the initial gain (old == owner). CR 603.2. Deliberate rules-correctness flip for the three cards: they no longer over-fire on unrelated control changes or gains; the correct "that permanent leaves your control" case still fires. Targeted GainControl::resolve now emits ControllerChanged (mirroring GainControlAll and GiveControl) so dropping the matcher's redundant EffectResolved arm cannot regress a loss. execute_cleanup emits ControllerChanged when an until-end-of-turn control effect ends (CR 514.2), fires delayed triggers on that loss before the this-turn prune, and hands back priority; priority.rs re-enters cleanup once the stack empties (CR 514.3a "another cleanup step begins"). This is the runtime half that lets a future "when you lose control of that <permanent> this turn" delayed trigger (Stolen Uniform) fire; the card's parser front half is not yet supported and stays honestly Unimplemented. Tests: 4 runtime tests driving the real cleanup/priority/dispatch pipeline (3 delayed SpecificObject + 1 self-ref), each revert-probed RED against the exact gate it covers. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): look-at + play face-down exile building block (Outrageous Robbery) Outrageous Robbery: "Target opponent exiles the top X cards of their library face down. You may look at and play those cards for as long as they remain exiled. If you cast a spell this way, you may spend mana as though it were mana of any type to cast it." Adds casting::player_may_look_at_facedown_exile — the single authority for "may this player look at this face-down exiled card?", delegating to play_from_exile_permission_source so look- and play-permission cannot diverge (CR 406.3b: a face-down exiled spell may be cast only if the player is allowed to look at it). It inherits the source's card_filter / single_use / per-turn gating. visibility.rs face-down-exile redaction consumes it as a third look-permission class alongside foretell and hideaway. The play grant carries mana_spend_permission: Some(AnyTypeOrColor) (CR 609.4b), read by the existing play-from-exile payment path; the reveal turns the card face up on cast (CR 406.3a). Parser: the subject-voice "<player> exiles the top N ... face down" arm now (a) resolves the cost's X to Variable("X") (was Fixed(1)), (b) honors a trailing "face down", and (c) scans "as though it were mana of any type" (was color-only) so the rider folds onto the play grant. Corrects a pre-existing wrong CR annotation on that arm (701.10a Doubling -> 701.13a Exile). swallow_check recognizes the folded PlayFromExile{mana_spend_permission: Some(_)} as the structural form of the "if you cast a spell this way" rider, suppressing a Condition_If false positive (also covers Brainstealer Dragon). Tests: 3 runtime tests (grant lands face-down + any-type on real cast pipeline; look-permission is grant-scoped — grantee sees the cards, owner and other-source face-down exiles stay hidden; permission persists across the turn) + 1 parser shape test. Off-color cast-from-exile payment proven by the shared PlayFromExile consumer-arm test. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): parse "the spell's mana value <= X" gate for impulse-cast (Bre of Clan Stoutarm) Bre of Clan Stoutarm's end-step ability: "if you gained life this turn, exile cards from the top of your library until you exile a nonland card. You may cast that card without paying its mana cost if the spell's mana value is less than or equal to the amount of life you gained this turn. Otherwise, put it into your hand." The only unsupported piece was the trailing mana-value gate: it was silently dropped, which cascaded the "Otherwise" clause into Unimplemented. Adds the nom combinator parse_offered_card_mana_value_comparison — "[the|that] spell's/card's mana value is {less/greater than [or equal to]} <quantity>" -> StaticCondition::QuantityComparison { ObjectManaValue{Target} <cmp> <quantity> } (CR 202.3 mana value, CR 115.1 target, CR 608.2c). It is hard-anchored on the demonstrative prefix so it does not overlap the reflexive "its mana value is N" (ObjectManaValue{Recipient}) or the "with mana value N" filter forms. Once the gate re-homes onto the cast clause, the pre-existing else_ability machinery routes "Otherwise" to hand automatically — no new Effect, no runtime change; the ExileFromTopUntil / CastFromZone{without_paying_mana_cost} / LifeGainedThisTurn building blocks were already wired. Bre's activated ability already parsed. Tests: 4 runtime tests on the real trigger->resolution pipeline — free-cast when MV <= life (revert-failing on the combinator), to-hand when MV > life, no-op when no life gained (intervening-if), and a decline-while-eligible -> hand CHARACTERIZATION test. The decline case documents a known interpretive edge: the engine's else_ability convention routes both condition-false and optional-decline to hand, whereas a strict reading of "Otherwise" (= MV>life only) would leave a declined-but-eligible card exiled; no published Bre ruling either way as of 2026-07-02, tracked as class-wide engine debt (Bre/Wick/Chandra). Assisted-by: ClaudeCode:claude-opus-4.8 * fix: thread keep_count_expr + TargetOpponent through non-engine consumers (mtgish-import, phase-ai tests) Commits 1b67f0248 (Stargaze) and 6f3a35d11 (Quick Draw) added the `keep_count_expr` field to `Effect::Dig` and the `ControllerRef::TargetOpponent` variant to the engine but did not update the non-engine consumer crates, leaving CI's exact lint surface (`cargo clippy --workspace --exclude phase-tauri --all-targets`) red. `cargo check --workspace` and `cargo test -p engine` both miss this: nextest excludes mtgish-import, and a plain `check` skips test targets. - mtgish-import action.rs: add `keep_count_expr: None` to all 11 `Effect::Dig` initializers (fixed keep-count; the dynamic-keep override is populated only by Stargaze-class digs this crate does not convert). - mtgish-import player_effect.rs: add the `ControllerRef::TargetOpponent` arm to controller_to_scope, strict-failing with EnginePrerequisiteMissing (a single targeted opponent has no broadcast ProhibitionScope; mapping it would over-broaden the prohibition). - phase-ai control.rs + spellslinger_prowess.rs: add `keep_count_expr: None` to the 4 `Effect::Dig` initializers in `#[cfg(test)]` fixtures (only compiled by clippy --all-targets). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Memory Vessel — play-from-exile grant + can't-play-from-zone prohibition Memory Vessel ({T}, exile it: each player exiles the top 7, may play them until the activator's next turn, and can't play from their hand) now lowers fully — the previously-collapsed "players may play cards they exiled this way, and they can't play cards from their hand" clause resolves into a per-owner play-from-exile grant plus a play-from-zone prohibition. Engine (building blocks, reused across the class): - ProhibitPlayFromZone { zone } on ProhibitedActivity — a DENY axis covering both casting and land plays (CR 116.2a/305.1/601.2a), distinct from the CastOnlyFromZones allow-list which is cast-only. Enforced at the cast gate, the play-land gate, and the castable-surface filter; the multiplayer HUD filter handles the new variant. - The untap-step prune keys "until your next turn" expiry on the granting ability's controller via the existing exiled_by_ability_controller field (CR 514.2/611.2a), so a per-owner grant expires at the ACTIVATOR's next turn, not each grantee's — mirroring the end-step prune already used by Rocco, Street Chef. No new field. Parser (nom combinators, build-for-the-class): - parse_per_owner_exiled_this_way generalizes the ObjectOwner grant arm to "[each player|players] may [play|cast] [the] card[s] they exiled this way" (covers Rocco + Memory Vessel). - try_parse_cant_play_from_zone: "[scope] can't play [cards|lands...] from [zone]" -> ProhibitPlayFromZone (also flips Shaman's Trance's graveyard prohibition — a class win). - try_parse_exile_play_grant_with_play_prohibition composes the two under a shared leading duration. Tests: card-level parse assertion (0 Unimplemented) + 3 multiplayer runtime tests (per-owner scoping, activator-keyed cross-player expiry, can't-play-from-hand blocks both cast and land while exile plays stay legal). Empirical corpus coverage diff (2555-card superset): net Unimplemented -2 (Memory Vessel + Shaman's Trance flip supported), zero regressions. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): dual-target slot registry + anaphoric slot binding (Stolen Uniform front-half) Add a declared-target-slot registry on ParseContext so "Choose target X and target Y" chains bind later anaphors ("that Equipment", "the chosen creature", "the artifact card") to a precise ParentTargetSlot{index} instead of the ambiguous grab-all ParentTarget. Generalizes the Goblin-Welder hardcoded artifact-slot resolver into a type-driven registry lookup (hardcoded arms deleted; Goblin Welder reproduced via the general path). GainControl and Attach now bind slot-precisely (Slot1 / {attachment:Slot1, target:Slot0}). Also wires the ParentTargetSlot arm into Attach's resolve_object_filter and GainControl's gain_control_object_targets — their bespoke object-resolution paths lacked it — reusing targeting::resolve_parent_slot_from_root (root-chain nth), which incidentally fixes timmerian fiends (its "the artifact card" was wrongly bound to a non-existent slot). Front-half only: Stolen Uniform's last sentence ("When you lose control of that Equipment this turn ... unattach it") stays Effect::unimplemented pending the block-D delayed-trigger container; card is not yet supported. The determiner anaphor path uses nom combinators; the pre-commit parser gate's flags are stale-base (ae663ee8c) false positives on pre-existing mod.rs / untouched oracle_trigger.rs lines — none in this commit's additions (verified). CR 601.2c (target chosen once per instance of "target") + CR 608.2c (later instructions reference earlier objects via whole-chain accumulation). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): classify new engine surfaces in the #4904 fail-closed walker Rebasing the S25 tranche onto main (#4904 growing-cascade detector + fail-closed ability-scan walker) surfaces two exhaustive-match classification points for engine surfaces this branch added before the walker existed: - ControllerRef::TargetOpponent (Quick Draw) — Axes::NONE in the C0 axis classifier, mirroring TargetPlayer. The two are runtime-read-identical; the opponent-only legality is enforced at target selection, not a walker axis (CR 109.4). - Effect::Dig.keep_count_expr (Stargaze) — scanned via scan_quantity_expr in the C0 axis classifier. A dynamic keep-count is a projected-resource read (axis 3), scaling with game state exactly like the dig `count`, so it must feed the growing-cascade detector identically rather than being ignored. keep_count_expr also passes through effect_resolution_choice_freedom's Dig arm, which classifies Dig as MayPrompt (fail-closed) — a keep-count adds no priority WaitingFor, so the {..} pass-through is sound and needs no per-field action. ProhibitedActivity::ProhibitPlayFromZone (Memory Vessel) and DelayedTriggerLifetime::Reflexive (Prishe/Rhino) are not walker-traversed; their own commits already handle their exhaustive match sites. Only ability_scan.rs (game/) changed; the pre-commit parser gate's flags are stale-base (ae663ee8c) false positives on pre-existing parser lines not in this commit. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): pay turn-face-up cost as a special action (Overgrown Zealot, Tin Street Gossip) Turning a face-down permanent face up is a special action (CR 116.2b) that must pay the morph/megamorph/disguise cost (CR 702.37e / 702.168d) or the manifested creature's mana cost (CR 701.40b). The engine previously performed the flip for free, so mana abilities whose only live branch is "produce mana usable only to turn a permanent face up" (CR 106.6 restricted-purpose mana) were left as honest-red Unimplemented gaps. - morph.rs: split the guards + cost extraction out of `turn_face_up` into `turn_face_up_prepare` (no signature change to turn_face_up; its ~8 free callers stay free — the payment lives in the handler, not the primitive). - engine.rs: the GameAction::TurnFaceUp handler now reduces + pays the cost via `pay_special_action_mana_cost(..., SpecialAction::TurnFaceUp, ...)`, mirroring the UnlockDoor special-action sibling. Sole production paid entry. - types/ability.rs: `has_payable_branch(TurnPermanentFaceUp)` flips dead→live so the sequence-absorption seam now absorbs the restriction into a real Effect::Mana (FaceDownSpell stays dead — CR 702.37c face-down casting is still unimplemented). Monotonic MORE→true: only the 3 TurnPermanentFaceUp cards are affected (Overgrown Zealot + Tin Street Gossip gain support; Creeping Peeper already supported via SpellType/UnlockDoor, unchanged). Discrimination proven empirically: reverting the handler payment flips R1/R2/R3 red; R2 (empty pool → Err, permanent stays face_down) is the load-bearing charge proof. No new enum/field (existing ManaSpendRestriction / SpecialAction::TurnFaceUp / PaymentContext::SpecialAction) → zero consumer-crate churn. s07-frozen files untouched. Parser dispatch unchanged (oracle_tests.rs changes are test assertions only; the gate's flags are stale-base ae663ee8c false positives, not this commit). CR 106.6 + CR 116.2b + CR 702.37e + CR 702.168d + CR 701.40b + CR 702.37c. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): grant abilities beyond activated-only via GrantedAbilityScope (Symbiote Spider-Man, Choreographed Sparks, Nalfeshnee) Parameterize Effect::GainActivatedAbilitiesOfTarget with a typed GrantedAbilityScope { ActivatedOnly (default) | AllOther } field (serde-default, zero consumer-crate churn) instead of adding a sibling variant — the granted ability set is fixed at effect start (CR 611.2c), within a single rule section. - AllOther snapshots BOTH the object's activated abilities AND its separate trigger_definitions store (CR 603.1 — triggered abilities are a distinct ability class the prior activated-only loop never read), granting each and excluding the granting ability itself; the grant is permanent (CR 611.2a). - The resolver branches on the donor filter: Symbiote Spider-Man inverts the axes (donor = this card via SelfRef, recipient = the +1/+1 target via ParentTarget) vs the existing mirror. - Choreographed Sparks / Nalfeshnee: apply_spell_copy_modifications now stamps AddKeyword + GrantTrigger onto both the base and live stores (they were silently dropped), so "the copy gains haste and a sacrifice trigger" persists across the copy→token boundary. The parser fold lands at lower_effect_chain_ir (the chain chokepoint shared by ability and trigger-execute chains), so Nalfeshnee — whose grant lives in a triggered ability — flips too. Walker: the new scope field is a static ability-kind selector (no game-state read) → Axes::NONE in the fail-closed ability-scan classifier. Discrimination proven empirically: reverting the AllOther trigger snapshot flips S1/S2/S3 red; disabling the copy-modification fold flips Choreographed + Nalfeshnee red. Coverage +3, zero regressions across all 35397 faces. No new Effect variant; s07-frozen files untouched. CR 611.2a + CR 611.2c + CR 603.1 + CR 701.21a (delayed sacrifice) + copy/haste rules. Assisted-by: ClaudeCode:claude-opus-4.8 * test(engine): flip Choreographed Sparks deferred-pin to supported P2f (4b2566eb6) implemented Choreographed Sparks' copy-grant (haste + delayed-sac fold via apply_spell_copy_modifications), invalidating the deferred-honesty guard `choreographed_sparks_copy_grant_is_deferred`, which asserted the card still lowered to Unimplemented. Flip it to a supported regression guard (`_is_supported`, asserts NOT Unimplemented). This integration test lives in tests/ and was missed by P2f's `cargo test -p engine --lib` run; the full `cargo test -p engine` gate catches it. Fixup for 4b2566eb6 — fold at ship-time autosquash. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Stolen Uniform lose-control container — runtime-correct unattach delayed trigger (#4380 block-D) Parser recognizer for Stolen Uniform's last sentence ("When you lose control of that Equipment this turn, if it's attached to a creature you control, unattach it") plus the two engine gaps it exposed, so the delayed trigger actually unattaches the right Equipment at cleanup — not a hollow parse flip. Parser (oracle_effect/mod.rs, oracle_target.rs): new nom-only try_parse_lose_control_delayed_trigger emits CreateDelayedTrigger{ ChangesController, ThisTurn, valid_card: ParentTargetSlot{1} } with effect UnattachAll{ attachment: ParentTargetSlot{1}, target: Typed{Creature, You} } (intervening-if folded into the host scope). Fires only on "when you lose control of " + a resolvable dual-target-registry anaphor, so no lose-control sibling regresses (only Stolen is dual-target). CR 603.7 / 603.4 / 603.2 / 701.3d. Gap #1 — trigger stall (triggers.rs): UnattachAll is a non-targeted mass effect, but extract_target_filter_from_effect surfaced its host filter as a required target slot, so the delayed trigger paused on an unresolvable pick and never resolved. Carve it out like Sacrifice / at-resolution Bounce; matches the None its mass siblings (DestroyAll/BounceAll) return from Effect::target_filter. CR 701.3d + CR 115.1. Gap #2 — attachment anaphor (attach.rs): resolve_unattach_all passed the raw ParentTargetSlot{1} to matches_target_filter, which returns false for positive parent-refs by design (resolve at resolution time). Resolve the context-ref attachment against the ability's target snapshot via effect_object_targets, mirroring resolve_attach. Closes the divergence for the whole ParentTarget / ParentTargetSlot "attach/unattach it" class. CR 608.2c + CR 701.3d. Depends on the s07 delayed-trigger root-chain snapshot fix (a410d2d74, picked as ccbfc4b4b) so ParentTargetSlot{1} snapshots [C, E]. Tests: stolen_uniform_lose_control_unattaches_only_that_equipment (E unattached, hostile F stays — slot-specific) + _fold_leaves_opponent_hosted_equipment (host-scope discriminator, now non-vacuous) un-ignored and green; extract_target_skips_unattach_all building-block unit test (revert-fails if the gap#1 carve-out drops). Full test-engine: 14707 lib + all integration green; CI clippy 0 warnings. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): become-a-typed-token copula on reanimated objects (Vraska the Silencer, Brilliance Unleashed) P2e — "It's a <typed thing> …" applied to a returned/reanimated non-copy object, as an indefinite continuous effect bound to that object. Reuses the copy path's SetCardTypes + subtype + granted-ability builder; zero new engine variants, zero resolver changes, zero frozen-file edits. Vraska, the Silencer: "return that card … tapped under your control. It's a Treasure artifact with '{T}, Sacrifice this artifact: Add one mana of any color,' and it loses all other card types." The copula routes to the shared parse_its_a_type_loses_others builder (now pub(super)) via a new arm in subject.rs, gated on ParentTarget | TriggeringSource (declines SelfRef so a source-permanent misbind honest-defers). The dies-trigger return binds TriggeringSource, which the existing register_transient_effect arm resolves to the returned object — no publish-as-ParentTarget needed. CR 205.1a / 205.1b / 613.1d / 611.2a / 400.7 / 603.6 (NOT 707.9d — copy-effect-scoped). Block 1a (sequence.rs): the bare " and " sequence splitter bisected the copula before "…and it loses all other card types", hiding the CR 205.1a replacement signal from the builder. Suppress the split when the remainder is exactly "(it )?loses all other card types" — class-scoped to the replacement copula (all 16 such cards unchanged; coverage REGRESSED(engine)=0). Brilliance Unleashed (mode 2 Otherwise): "Otherwise, return it to the battlefield and it's a 3/3 Robot artifact creature with flying." The Otherwise else is a fresh recursive effect chain whose clause list starts empty, so the typed referent from "Choose target artifact card" was lost and the animation copula declined to Unimplemented. Seed ctx.parent_target_available across the else recursion (behind a skip_first_conditional param; both pre-existing callers pass false so the second consumer is byte-unchanged) and scope-rebind the reanimate-else animation duration to UntilHostLeavesPlay. Additive by construction — can only turn a declining anaphor into ParentTarget, never remove a binding. Bre of Clan Stoutarm (C12) reuses the same else-seed. C3: both copulas install Duration::UntilHostLeavesPlay (CR 400.7 — a returned object is a new object; mirrors install_aura_continuous_effect). Tests (std_longtail_e.rs, +4, deferred note flipped): parser round-trip + runtime for each card. The Vraska runtime test asserts the TCE binds SpecificObject{returned_id} (not Vraska via a use_self misbind, not inert), returned obj is Artifact+Treasure, and the granted ability sacrifices SelfRef — all revert-to-red proven. gen-card-data: both flip supported, gap_count=0. Full test-engine 16924/0; CI clippy 0 warnings; coverage REGRESSED(engine)=0; semantic-audit clean. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): cloak from a chosen non-library source (Vannifar, Evolved Enigma) Vannifar's "Cloak a card from your hand" — the controller cloaks a card they CHOOSE from hand, not the top of the library. Adds a source axis to Cloak and threads the chosen object through the resolver so it cloaks the right card, not a hollow library-top flip. Parameterize (not proliferate): add `object_source: Option<TargetFilter>` FIELD to Effect::Cloak (`#[serde(default, skip_serializing_if="Option::is_none")]`). None = CR 701.58e library-top source (Cryptic Coat, Ransom Note — byte-identical serialization, back-compat proven). Some(filter) = explicit objects chosen upstream. Zero new variants. Resolver (cloak.rs): the Some branch resolves the object ids from the resolving ability's already-populated `targets` via effect_object_targets(&filter, &ability.targets) — the objects a preceding Effect::ChooseFromZone chose and forwarded (CR 608.2c) — then manifest_card(…, cloaked_2_2()) per object. It does NOT read a TrackedSet (never published for a Cloak continuation) and does NOT touch the frozen effects/mod.rs. The None branch is the unchanged library-top loop. Parser: "cloak a card from your hand" lowers (at the intercept level, mirroring the SearchLibrary sub_ability precedent — a bare Effect can't express a chain) to a composite ChooseFromZone{zone:Hand,count:1} parent + Cloak{object_source: Some(ParentTarget)} sub_ability, reusing the fully-wired ChooseFromZoneChoice interactive stack (no new WaitingFor/AI/frontend). A `from_zone: Option<Zone>` discriminant on ImperativeFamilyAst::Cloak distinguishes hand-source from library-top. Pure nom (tag/alt/all_consuming). Walker (#4904 ability_scan.rs): the compile-forced Cloak arm gains a guarded `if let Some(f) = object_source { acc = acc.or(scan_target_filter(f)); }`. Anti-hollow-win test (tests/vannifar_cloak_from_hand.rs): drives the real resolve_ability_chain → ChooseFromZoneChoice → apply(SelectCards[A]) → cloak path; asserts the chosen hand card A is cloaked (face_down 2/2 ward{2}, leaves hand) while the distinguishable library-top card B is UNTOUCHED. Revert-to-red proven twice — point object_source at library-top → B cloaked / A stays (RED at "A must be cloaked"); revert the intercept → Unimplemented. Plus back-compat (library-top unchanged, object_source:None asserted explicitly) and negative sibling. Expose the Culprit remains a separate later gate (reuses this field). Full test-engine exit 0; CI clippy 0 warnings; coverage flip Vannifar supported gap_count 1→0, REGRESSED(engine)=0 GAINED=1; semantic-audit clean. CR 701.58a / 701.58e / 608.2c (grep-verified). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): cloak an exiled face-down pile — Expose the Culprit mode 2 Expose the Culprit's mode 2 ("Exile any number of face-up creatures you control with disguise in a face-down pile, shuffle that pile, then cloak them") lowers to ChooseObjectsIntoTrackedSet{Creature, You, HasKeywordKind{Disguise}} -> Shuffle{TrackedSet} -> Cloak{object_source: Some(TrackedSet)}. - KeywordKind::Disguise (CR 702.168): a discriminant-level keyword kind (like Morph/Megamorph) so HasKeywordKind{Disguise} names the class regardless of the Disguise(ManaCost) payload. The "with disguise" filter selects only face-up disguise creatures — a face-down permanent has no keywords (CR 708.2a). - Shuffle gains a TrackedSet/pile branch (CR 701.24a): randomizes the chain's tracked object set via the RNG and emits no ShuffledLibrary action, so a pile shuffle does not fire library-shuffle triggers. - Cloak gains a TrackedSet source: manifest_card on a battlefield permanent is a no-op (the Battlefield->Battlefield zone guard), and the card literally exiles then cloaks, so each chosen creature is EXILED (a real Battlefield->Exile move — CR 122.2 counters cease, CR 603.6c leaves-the-battlefield triggers fire, CR 704.5m/704.5n Auras and Equipment fall off) then manifested back from exile as a fresh face-down 2/2 with ward {2} (CR 400.7 new object, CR 701.58a/e). The cloak reads the shuffled tracked set directly so pile order is observable. Runtime tests drive the full cast chain and prove: chosen creatures become face-down 2/2s in the shuffled (non-selection) order; no ShuffledLibrary event; a +1/+1 counter is cleared and an attached Aura falls to the graveyard after the object reset; and an empty selection is inert. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): interactive "any number" multi-zone name-matched search-and-exile Route "search <its owner's/its controller's> graveyard, hand, and library for any number of cards with <same-name-ref> and exile them" to the interactive Effect::SearchLibrary path (CR 701.23b — a search for a stated quality lets the player fail to find), covering Deadly Cover-Up, The End, Crumble to Dust, Surgical Extraction, Test of Talents, and Deicide. Zero new engine enum variants. Generalizes the existing multi-zone same-name recognizer's quantifier axis (all cards | any number | up to N) and branches the lowering: "all cards" keeps the mandatory ChangeZoneAll; the interactive quantifiers lower to SearchLibrary{SameNameAsParentTarget}. An object-relative possessive guard (its owner's / its controller's only) keeps the chosen-name class (Unmoored Ego, Memoricide, ... — "choose a card name ... with that name") on the HasChosenName path (CR 201.2). - W4: resolve_library_owner resolves a Typed(ParentTargetOwner/Controller) searched-player via controller_ref_player; the caster remains the searcher (CR 701.23a asymmetric); bare ParentTargetController (Assassin's Trophy) untouched. - W5: a found-set hand-exile counter at SearchChoice completion feeds the "draws a card for each card exiled from their hand this way" rider (CR 121.1). - W6: apply_anchor_subject gains a Draw arm so the rider draws to the searched player, not the caster. - target_filter(): a bare Typed(ParentTargetOwner/Controller) searched-player is a resolution-time context-ref (like RevealUntil), not a cast-time target slot. Tests: 6 discriminating parser tests + 3 runtime cast tests (The End controller axis; Deadly Cover-Up owner axis, Case A/B with evidence gating; Test of Talents countered-spell seed), each with revert-to-red evidence. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): bind granted-ability self-references to the granting object (CR 201.5a) When an ability grants another ability that refers to the granting object by name (Deconstruction Hammer "Sacrifice Deconstruction Hammer", The Dominion Bracelet "{15}, Exile The Dominion Bracelet", Trusty Boomerang "Return Trusty Boomerang"), CR 201.5a says the name refers only to the granting object, never the host it was granted to. Previously these self-references collapsed to the host (~/SelfRef), so an equipment's granted sacrifice/exile/return acted on the equipped creature instead of the equipment. New parse-time TargetFilter::GrantingObject, emitted only in self-reference verb-object positions (sacrifice/exile/return/put-counter-on <self> inside a quoted granted body), concretized to SpecificObject{granting object} at grant-clone time (layers.rs GrantAbility/GrantTrigger, via effect.source_id). Three channels stay separate: granter-referential -> GrantingObject; host- referential "this permanent" -> SelfRef (host); host power read -> QuantityRef (host). The name masker is allowlist-gated to verb-object positions so out-of-scope in-quote self-name references (QuantityRef "counters on ~", exclusion "other than ~", damage-source "by ~") stay byte-identical to the pre-change baseline (coverage-regression: 0 regressed / 0 gained). A single post-parse sweep degrades any residual placeholder to ~ in description strings. Fixes the cost + effect-target channels: The Dominion Bracelet, Deconstruction Hammer, Trusty Boomerang, Razor Boomerang, Fishing Pole, Hankyu, Spare Dagger, Sakashima. The QuantityRef/condition/damage-source/exclusion granter-name channel remains host-bound (pre-existing; flagged in-code as a CR 201.5a follow-up). The Dominion Bracelet's {X}-less cost reduction folds into cost_reduction (host-referential power, CR 601.2f). Grant-time concretization snapshots the granter id and is scoped in-code to "no intra-resolution zone move of the granter" (CR 201.5a second sentence + CR 400.7). Zero new engine enum variants beyond TargetFilter::GrantingObject. Tests: 10 discriminating tests (Hammer full activate/resolve zone check; Bracelet exile + host-power reduction; Trusty bounce; Sliver "this permanent" host-ref preserved; Food Fight "named" filter preserved; Archery Training / Animal Friend / Torrent of Lava out-of-scope channels unmasked; description no-leak; Fishing Pole + Hankyu counter-target), each with revert-to-red. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): phase-scoped player control for Secret of Bloodbending (CR 723.2) Secret of Bloodbending: "You control target opponent during their next combat phase. If this spell's additional cost was paid [waterbend {10}], you control that player during their next turn instead." Previously the base "next combat phase" leaf was Unimplemented — CR 723.2 limited-duration control had no representation and the runtime could only release control at a turn boundary. Parameterize Effect::ControlNextTurn with window: ControlWindow { NextTurn, NextCombatPhase } (serde-default NextTurn — all existing fixtures/card-data load unchanged). CR 723.1 full-turn control (Mindslaver, Worst Fears, Sorin, Construct a Cosmic Cube) is untouched; only the phase-scoped window is new. Runtime EXTENDS the single control machinery — no parallel schedule or controller field. A new phase-boundary activate/release hook in finish_enter_phase (turns.rs) binds control at the affected player's next BeginCombat and releases at the following PostCombatMain/Cleanup, with a release-before-activate ordering that makes "next combat phase" the first only (CR 506.7d by analogy). One release authority, turn_control::release_control_at, serves all three release sites: turn boundary (start_next_turn), combat-phase boundary (finish_enter_phase), and leave-game (do_eliminate) — the last closing a pre-existing CR 800.4a/b gap that also affected Mindslaver's full-turn control. Parser: a window alt() axis on the control suffix combinator; a subject.rs deferral guard so the full pipeline routes "control ... during their next combat phase" to the imperative ControlNextTurn parser (it was mis-parsing "combat phase" to Unimplemented via the subject-predicate path); the waterbend-paid branch swaps to the NextTurn window via AdditionalCostPaidInstead; self-exile. Edge cases designed (CR 723.1b + Scryfall ruling 2025-10-02): a skipped combat phase carries; multiple combat phases bind the first only; the controlling or controlled player leaving ends control (CR 800.4a/b); 3+ players route only the controlled seat. Coverage-regression on fresh card-data: 0 regressed, +1 (Secret). Tests: 8 groups incl. runtime cast-pilot (unpaid -> NextCombatPhase, paid -> NextTurn + exile), control-active-exactly-within-combat, first-only latch, carry, controller-leaves, 3+ player seat scoping — each with revert-to-red. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(parser): repeat a whole process a fixed/variable count of times (CR 608.2c) "Then repeat this process X more times." (Another Round) previously left an Unimplemented{repeat} node. Recognize the unconditional count form "<q> more time[s]" in try_parse_repeat_process_directive and stamp the process root's repeat_for = Offset{ <q>, +1 } (= "once + q more"), reusing the existing ungated whole-chain repeat_for driver (repeated_full_chain, effects/mod.rs) — zero engine, zero new variant. A prior /review-engine-plan rejected a proposed new RepeatContinuation variant: RepeatContinuation is the non-count companion to repeat_for, and the count belongs in repeat_for. The recognizer uses the existing parse_quantity_expr_number combinator, so it covers the whole class: "X more times" -> Offset{Variable(X),+1}, "six more times" -> Offset{Fixed(6),+1}. Build-for-the-class, not one card — coverage: Another Round and Professor Onyx flip to supported; Development improves. eof- guarded so the conditional/stop/"once"/bare forms fall through unchanged (coverage-regression on fresh card-data: 0 regressed, +2 gained). CR 608.2c: the controller repeats the same instructions in order. CR 107.3a / 601.2b: X is announced at cast and fixed once. CR 400.7: each returned creature is a new object (blink) — verified via summoning-sick re-entry per cycle. Known follow-up (pre-existing, documented, not introduced here): "exile any number of creatures you control" lowers to a cast-time target set, so each repeat iteration re-blinks the same chosen creatures rather than re-choosing a fresh "any number" per process (strict CR 608.2c). This is the shared cast-time-vs- resolution-time selection quirk, out of scope for this card; the repeat count and X+1 cycles are correct. Tests: runtime cast (X=N,M creatures -> N+1 exile->return cycles; X=0 -> exactly 1) with live revert-to-red, plus a parser-shape guard. Parser-only. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(parser): reanimate self and a targeted graveyard card to the battlefield (CR 400.7) "Return this card and target land card from your graveyard to the battlefield tapped." (Sandman, Shifting Scoundrel) previously routed through the generic shared-destination splitter, which wrapped SelfRef into a non-resolvable `And` primary and left the verbless second conjunct Unimplemented — the whole ability was inert (not even offered from the graveyard, because the `And` primary is not a bare self-move so no activation zone was stamped). Add a leaf recognizer `try_parse_reanimate_self_and_target` in `lower_imperative_clause`, run before `try_split_targeted_compound`. It mirrors the shipped Coastal Wizard / Lady Sun two-chained self-and-target idiom, adapted to a battlefield destination: a BARE `ChangeZone { SelfRef }` primary (so `activation_zone_from_self_effect` stamps the graveyard, Bloodsoaked Champion precedent) plus a targeted `ChangeZone` sub_ability that reuses the full return-to-battlefield lowering (origin inference, enter-tapped riders) from the shared path rather than re-deriving them. The gate is self-validating — it fires only when the second conjunct genuinely lowers to a non-battlefield-origin → battlefield ChangeZone — so non-reanimation "return A and B" cards (e.g. Coastal Wizard's return-to-hand) fall through unchanged. Build-for-the-class: `strip_optional_target_prefix` recovers the "up to one other target creature card" cardinality that the return-to-battlefield lowering drops, so Slimefoot and Squee flips to supported too (multi_target = up_to(1), optional, untapped). Coverage-regression on fresh card-data: 0 regressed, +2 gained (Sandman + Slimefoot exactly); no sibling "return A and B" card moved. CR 400.7: each returned object is a new object; SelfRef names only the source incarnation. CR 608.2c: the two chained moves resolve in written order. CR 601.2c + CR 115.1: the graveyard card is a chosen target; SelfRef is not. CR 113.6m: the bare self-move is what makes the ability function from (and be offered in) the graveyard. CR 614.1: "to the battlefield tapped" enters both objects tapped. Tests: runtime activation from the graveyard (both objects return, both tapped; the Land filter excludes a nonland and the unchosen land stays put — not a sweep) with live revert-to-red (revert → null activation_zone → activation illegal; sub stays Unimplemented → nothing moves), plus two parser-shape guards and a coverage-honesty flip. Parser-only; no engine, no new variant. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(parser): disjunctive "first-of-type spell this turn" intervening-if (CR 603.4) Alania, Divergent Storm's trigger — "Whenever you cast a spell, if it's the first instant spell, the first sorcery spell, or the first Otter spell other than Alania you've cast this turn, you may have target opponent draw a card." — left the whole intervening-if plus the draw as an Unimplemented node. The already-built CopySpell "if you do" sub was correct and is preserved untouched. Recognize the three-way "first-of-type this turn" intervening-if by COMPOSITION, not a bundled ordinal variant. Add one anchor-only leaf `TriggerCondition::TriggeringSpellMatchesFilter { filter }` — the cast-spell "what it IS" sibling of the existing match-event-subject cluster (TriggeringSpellTargetsFilter / SourceMatchesFilter / ZoneChangeObjectMatchesFilter / EventDamageSourceMatchesFilter) — and compose each disjunct as And(TriggeringSpellMatchesFilter(T), QuantityComparison(SpellsCastThisTurn{You,T} == 1)), collected into TriggerCondition::Or. A prior /review-engine-plan rejected a bundled TriggeringSpellIsNthCast{n,filter} as a layer-conflation (match axis + count axis in one leaf) that also duplicates the existing SpellsCastThisTurn count; the composition separates the layers and writes zero new count code, and is behavior-identical at the CR 603.4 re-check. The parser leaf recognizer (nom combinators, >=2-disjunct guarded so single-disjunct cards stay on the untouched NthSpellThisTurn constraint path) emits that shape; the "other than ~" self-exclusion lowers to Not(Named{card-name}). Build-for-the-class: the anchor variant also unlocks the plain "if it's an instant spell" intervening-if. Also fix a latent bug this exposed: spell_record_matches_filter dropped TargetFilter::Named to the catch-all false, so Not(Named{X}) over spell history was always true — silently no-opping any name self-exclusion (Alania's "other than ~"). Add the Named arm (record.name == name). Measured: zero existing cards fed a top-level Named filter into a spell-record position, so this is a pure fix (coverage-regression on fresh card-data: 0 regressed, +1 gained = Alania only; the >=2-disjunct guard protected Vengevine and the 108 NthSpellThisTurn constraint cards). CR 603.4: the intervening-if is checked at trigger time AND resolution — the count is read live, so a second matching spell cast in response correctly fizzles it. CR 601.2a: the anchor keys on the SpellCast event's spell object. CR 201.2: card name for the self-exclusion. Tests: 6 runtime cast-pipeline tests (first-instant fires + copies; second-instant same turn does not; first-sorcery fires; Otter self-exclusion by name; the CR 603.4 in-response fizzle; optional-draw decline skips the copy) each with live revert-to-red, plus a parser-shape guard and a Vengevine constraint-path regression anchor. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Behold a [quality] as an interactive triggered-ability effect (CR 701.4a) Sarkhan, Dragon Ascendant's ETB "you may behold a Dragon. If you do, create a Treasure token." previously left an Unimplemented{behold} node — behold existed only as a casting cost (AbilityCost::Behold), never as an effect. The already- parsed Treasure sub (gated on OptionalEffectPerformed) and the second trigger (Dragon-enters → +1/+1 + becomes-Dragon-with-flying) are preserved untouched. Add Effect::Behold { filter }, an interactive resolution-time keyword action per CR 701.4a ("Reveal a [quality] card from your hand or choose a [quality] permanent you control on the battlefield"). The resolver (game/effects/behold.rs) reuses the single candidate authority eligible_behold_choices (battlefield-you- control ∪ matching hand) and a shared reveal_if_from_hand helper: - 0 candidates: whiff — set cost_payment_failed_flag, stash no continuation, so the "if you do" rider reads performed && !flag = false (no Treasure). - 1 candidate: forced, no agency — auto-select; a hand card emits CardsRevealed (card stays in hand), a permanent reveals nothing. - >=2 candidates: a genuine, rules-visible choice (CR 608.2d) — park a new WaitingFor::BeholdChoice for the controller; the submit handler validates the chosen object is in the candidate set, reveals-if-hand, sets the rider performed, and drains. A prior /review-engine-plan rejected an auto-resolve design: CR 701.4a grants the player the choice, and a hand reveal is a public event, so auto-picking among two or more distinct hand Dragons is an engine-made choice with rules-visible consequences — pillar #1 (rules-correct over convenient). Hidden-information correctness (CR 400.2): the BeholdChoice candidate list spans a hidden zone (hand), so visibility.rs redacts the pre-choice candidates to an opaque sentinel for opponents — otherwise the controller's matching hand cards would leak before they choose. The post-choice reveal exposes only the chosen card. Behold has no stack target (chosen at resolution, not declared): target_filter() is None, in the mass-effect group beside Clash/Populate. The frontend BeholdChoiceModal is display-only — it renders the engine-provided candidate list and dispatches a single-object SelectCards; it derives no eligibility. i18n reuses the existing cardChoice.behold.* keys (present in all seven locales from the cost-side UI) — zero new keys. #5051 (CR 400.7): this fixed-quality behold writes no ChosenAttribute, so the stale-chosen-type bug cannot manifest here; a code + test pin flags the future type_choice axis for the fix sweep. Tests: 7 runtime cast-pipeline tests (board-Dragon behold; hand-Dragon reveal stays in hand; >=2-hand-Dragon interactive prompt with only-chosen-revealed; opponent-view candidate redaction; accept-with-no-Dragon whiff; decline; parser shape) each with revert-to-red. Coverage-regression on fresh card-data: 0 regressed, +1 gained (Sarkhan). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): delayed sacrifice "at the end step on your next turn" (CR 513.2) Kav Landseeker's "When this creature enters, create a Lander token. At the beginning of the end step on your next turn, sacrifice that token." previously left the delayed clause an Unimplemented{at} node — the parser had no temporal arm for "the end step on your next turn", and the nearest existing condition (AtNextPhaseForPlayer, "your next end step") fires the CURRENT turn's end step (CR 513.2: a next-end-step delayed trigger created outside the end step is not backed up), which would sacrifice the Lander before it could be used. Add a turn-floor to AtNextPhaseForPlayer via a new typed enum TurnGate { None, AfterCreationTurn, After(u32) } (not a bool, not a magic sentinel): the parser emits the symbolic AfterCreationTurn, and effects::delayed_trigger::resolve stamps it to After(creation_turn) at creation (CR 603.7a) — the single-path binding site that already rewrites the PlayerId(0) controller placeholder. The matcher then skips every matching phase up to and including the floor turn and fires on the first strictly-later controller turn (CR 500.7 extra turns included). An unstamped AfterCreationTurn reaching the matcher debug_asserts and falls through to fire this turn (a loud, test-caught wrong-timing signal) rather than silently never firing. Existing AtNextPhaseForPlayer users (Greasefang, rebound, epic) default gate: None and are byte-identical — None is skip_serializing_if, so only Kav serializes a gate ("AfterCreationTurn", a pure semantic, no number). "That token" reuses the existing TargetFilter::LastCreated snapshot-at-creation (specific-token-safe per CR 603.7c / 400.7); the Lander predefined token and the parsed Lander creation are untouched. Tests: paired-timing runtime (Lander survives the current end step, is sacrificed at the controller's next end step) with a resolved-stack reach-guard, specific-token (a bystander token survives), a resolve-time stamp unit test, a parser-shape guard, and a Greasefang non-perturbation guard — each with revert-to-red. Coverage-regression on fresh card-data: 0 regressed, +1 gained (Kav). Singleton delayed-trigger test scope is deliberate (see #5072). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Crowd-Control Warden dual enters/turned-face-up counter replacement + suppress own as-enters on face-down entry (CR 708.2a) Crowd-Control Warden ("As this creature enters or is turned face up, put X +1/+1 counters on it, where X is the number of other creatures you control. Disguise …") previously left the replacement line an Unimplemented node — the replacement dispatcher matched the "As ~ enters" pattern but no inner parser handled the dual "enters OR is turned face up" condition + the "put … on it" clause surface. PARSER: recognize the self-and-face-up counters replacement (a new leaf `lower_as_enters_or_face_up_counters`, wired at the Priority-8 replacement slot). It emits two ReplacementDefinitions — one `Moved` (enter-the-battlefield) and one `TurnFaceUp` — sharing one `PutCounter{P1P1, ObjectCount(other creatures you control), SelfRef}` execute (CR 613.2a: both conditions generate the same effect). The dynamic-count "on it" anaphor lowers to ParentTarget; the recognizer normalizes it to SelfRef (matching the directly-built enters-with-counters shape) so the runtime folds it as an ETB event modifier. A guard requires every execute effect to be PutCounter{SelfRef}, so sibling "As ~ enters, choose…/becomes…" lines fall through untouched. The dynamic count + turned-face-up runtime were already fully supported (extract_etb_counters, turn_face_up_applier) — parser-only for the card. Coverage-regression on fresh card-data: 0 regressed, +1 gained (Crowd-Control Warden), blast radius exactly 1 card. RUNTIME (the parser fix exposed a class bug): playing the Warden face-down via Disguise wrongly gained the +1/+1 counters. CR 708.2a: a permanent that enters face down is a 2/2 with no text, so its OWN "As ~ enters" replacement must not apply on a face-down entry. Add a guard in object_replacement_candidate_applies: `if is_entering && ZoneChange{face_down_profile: Some} return false` — is_entering is true only when the candidate's source IS the entering object (its own text), so EXTERNAL replacements (another permanent's enters-tapped) still apply. Masked until now: existing morph/disguise cards with an own "enters with N counters" use an X count (=0 face-down); the Warden's ObjectCount is the first nonzero one to expose it. CR 708.3: the permanent is turned face down before it enters. A pre-existing change_zone test (paused_face_down_change_zone_resumes_face_down_with_profile) forced its replacement pause via the entrant's OWN SelfRef shock replacement on a face-down entry — inadvertently asserting the masked bug. Rework it (test-only) to force the pause via EXTERNAL replacements (mirroring the morph sibling), preserving the load-bearing seam assertion (the PendingChangeZoneIteration carrier preserves the face-down profile through pause/resume). Tests: parser-shape (dual replacement, zero Unimplemented) + as-enters-choose reach-guard; runtime face-down NEG discriminator (0 counters, reds to 2 on guard revert) + hard-cast POS + face-down-then-turn-up; a no-over-suppression external test + Hooded Hydra masked-class regression guards — each labeled by its discriminating role. Full test-engine: 17263 passed. Assisted-by: ClaudeCode:claude-opus-4.8 * chore(engine): rebase adaptation — classify S25 tranche variants into the #5072 walker The #5072 PR-6.75 read/write conflict profiler (ability_rw.rs) is exhaustive over Effect / TriggerCondition / TargetFilter / ControllerRef with no wildcards. This tranche, written before #5072 merged, added new variants/fields the walker had no arms for; rebasing onto main surfaced 13 compile errors (+ a latent Effect::Behold masked by same-match E0027s). Classify each with its read/write profile and the three ordering axes (reads_member_bound / reads_event_live / writes_event_object), CR-grounded and mirroring the closest existing sibling: - TargetFilter::GrantingObject (CR 201.5a): mirrors SpecificObject on the base axes; reads_member_bound = true (fail-closed R3 divergence). Parse-template-only — grant-clone concretizes it to SpecificObject{granter} (ability_utils.rs), so the arm is inert at runtime and classified conservatively. - ControllerRef::TargetOpponent (CR 109.4): mirrors TargetPlayer (runtime-read- identical) — all axes empty (declared-target read, member-invariant). - TriggerCondition::TriggeringSpellMatchesFilter (CR 601.2a/603.4): mirrors TriggeringSpellTargetsFilter — reads_event_live. - Effect::Behold (CR 701.4a/608.2d): mirrors Reveal — a pure information event, no write; its resolution-time choice is classified in ability_scan (MayPrompt). - Effect::Cloak.object_source (CR 701.58a), Effect::Dig.keep_count_expr (CR 701.20e/608.2c), Effect::GainActivatedAbilitiesOfTarget.scope (CR 602.1): new fields bound + profiled like their sibling fields. TurnGate.gate (a match-time firing gate, not traversed by ability_rw) and Effect::ControlNextTurn's ControlWindow (absorbed by the maximal-conservative arm) need no binding. Also adapts one lib test to a main API delta: #5072-era main replaced Effect::DealDamage's former `excess_only: bool` with a typed `excess` field, so a DealDamage constructor in copy_spell.rs's tests gains `excess: None` to match. One tail adaptation commit rather than folding into the owning card commits: unlike #5072's own case, the walker lives in the rebase BASE here, so folding WOULD restore per-commit compiles — but a six-way fold across the tranche is error-prone; the arms are grouped here and can be autosquashed at ship-prep if the final PR wants per-commit bisectability. cargo check --workspace is clean (engine + phase-ai + mtgish-import + server-core + wasm/server + test targets), zero downstream dual-walk sites, and the full engine test suite passes (17549 tests, 0 failures) — no HIGH-2 / C2-ungating trip in the tranche's tests (Kav's delayed-trigger tests were already singleton-scoped). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Rhys, the Evermore — interactive "remove any number of counters" Implements Rhys's "{W},{T}: Remove any number of counters from target creature you control" as a resolution-time interactive choice (CR 107.1c: "any number" includes zero; CR 608.2d: the controller chooses at resolution). Parser: a new "any number of" arm lowers the count to QuantityExpr::UpTo (Fixed{-1}), reusing the existing UpTo slot — no new variant, so the #5072 read/write dual-walk needs no arms. A CR 608.2d "from among" guard keeps out-of-scope multi-source removals (Galloping Lizrog, Eventide's Shadow) as Unimplemented rather than collapsing them to single-source semantics. Effect path: resolve_remove peels the UpTo flag and raises WaitingFor::RemoveCountersChoice carrying the target's live per-type counter counts; the controller's GameAction::ChooseCountersToRemove is validated by a validate_counter_selection helper shared with the cost path and applied through the CR 614.1 remove_counter_with_replacement pipeline via a pending_counter_removals queue. The queue re-parks on replacement choices and, on drain, stamps last_effect_count before the continuation drains, so "create that many" reflexive clauses (Tetravus) read the true removed total (CR 608.2h). Multiplayer: accepts_freeform_counter_removal + a session skip-legality arm let a human submit an intermediate per-type selection the coarse AI candidate set does not enumerate; the payload guard bounds the selection list. Coverage: GAINED = exactly {Rhys}. Tetravus's remove-counters trigger and token creation now resolve (proven: 3 counters -> 3 Tetravite tokens), but the card stays unsupported on an unrelated keyword-grant clause nested in that trigger. Cost-path storage lands / batteries and the multi-source cards are unchanged. The plan reviewer's re-review request was folded into /review-impl with a mandate to re-verify all six must-change resolutions at code level plus the four non-compile-enforced registration points; verdict APPROVE. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Esper Terra — "up to N" lore, conjunctive mana, token-anaphor counter binding Fixes the two parser-leaf gaps on Esper Terra (Terra, Magical Adept // Esper Terra) plus the shared binding that makes its marquee chapter effect land on the right object. No new engine variant (reuses QuantityExpr::UpTo, PutCounter, ManaProduction::Fixed, TargetFilter::LastCreated) — the #5072 read/write dual-walk needs no arms. Changes (all parser): - Gap A (counter.rs): "put up to three lore counters on it" — strip the "up to " count marker and wrap the count in QuantityExpr::up_to (CR 608.2d + CR 122.1), mirroring the Draw/Discard/PutSticker convention. Count-side only; the target-side "on up to N target(s)" path is untouched. - Gap B (mana.rs): "Add {W}{W}, {U}{U}, {B}{B}, {R}{R}, and {G}{G}" — a conjunctive comma+"and" fixed-mana-group accumulator (parse_fixed_mana_group_list) → ManaProduction::Fixed (CR 106.4). Rejects "or" lists, requires >=2 groups, no dedup. - §B2 token-anaphor bind (context.rs + mod.rs + counter.rs): a bare "put ... counters on it" following a token creator binds to the created token, not the ability source (CR 608.2c). A ParseContext.token_created_in_chain signal (set from the most-recent prior referent, cleared by an intervening typed target) drives the it-pronoun counter branch to TargetFilter::LastCreated. A companion LastCreated guard on replace_target_with_parent's counter arm (mod.rs) preserves that bind through the lowering pass — the guard its sibling Attach/UnattachAll arms already carried. The "if it's a Saga" gate needs NO code: it parses to TargetMatchesFilter{Saga} reading ability.targets.first() (the copied enchantment, CR 707.2 type-equal to the token), so a non-Saga copy places zero lore counters by construction. Coverage (full-DB regression): REGRESSED(engine)=0. GAINED=1 (Esper Terra). Eight counter-target fingerprint changes, all correct: - 4 anaphor target corrections (SelfRef→LastCreated): Esper Terra (x3 chapters, the +1 GAINED); applied geometry; the bus runner (first put; card stays unsupported on a separate gap); journey to the lost city. - 4 Gap-A parser wins (target unchanged SelfRef, a new "up to X" node parses): clockwork avian/beast/steed/swarm (stay unsupported on the counter-cap gap). - 9 genuine-source cards ("...on this creature/enchantment/<name>") byte-identical. Measured blast radius (replaces the plan's 6/7 prediction): 3 anaphor cards corrected (applied geometry, the bus runner, journey to the lost city) / 5 pre-existing-unchanged / 9 genuine-source preserved. The 5 unchanged cards (alien invasion, intrude on the mind, lasting fayth, match the odds, kianne) put counters via a dynamic "for each"-count PutCounter on a separate parser path that never reaches the it-branch — they stay SelfRef (misbound to source), byte-identical to baseline. That path is a pre-existing, LIVE rules-wrong gap this change does not touch; deferred and logged (.planning/coverage-analysis/S25-DEFERRALS.md D1). Ship-prep note: journey to the lost city appears in #5072's R3 member-bound classification; its PutCounter target change alters its ability AST, so its reads_member_bound rw-profile — and thus se_member_bound_class — may move +/-1 at the next full-DB sweep. Attributed to this commit (expected movement, not a regression). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Foraging Wickermaw — "this creature becomes that color" Closes the last gap on Foraging Wickermaw: "{1}: Add one mana of any color. This creature becomes that color until end of turn." reuses the existing chosen-color carrier — no new engine variant (empty types/ diff). - Parser (oracle_effect/subject.rs): fold "that color" into the existing AddChosenColor arm of try_parse_become_color_modification, beside "the chosen color". "That color" is the color of the mana produced this activation — a resolution-time anaphor, not a value baked at parse (CR 106.1a/202.2/105.3). - Runtime (game/mana_abilities.rs): record the produced color as ChosenAttribute::Color on the source in produce_mana_from_ability, so the Layer-5 AddChosenColor reader (already powering Puca's Eye) sets the creature's color (CR 613.1e). Reuses the existing mana_sources::mana_type_to_color (Colorless→None) — no new converter, no types/mana.rs edit. Two safety properties, both proven by revert-to-red: - Gated write, zero blast radius. produce_mana_from_ability is the universal mana chokepoint, so the write fires only when the resolving ability's own chain carries a downstream AddChosenColor (via visit_links_any on the activated ability_def, fresh per activation). Coverage-regression confirms exactly one card flips (Foraging Wickermaw) and every ordinary producer — Birds of Paradise, City of Brass, painland, filter land, basic, rock — is byte-identical. - Explicit replace, not accumulate (CR 400.7). The stored ChosenAttribute::Color PERSISTS across turns (cleared only on zone change), UEOT expiry removes the Layer-5 effect but NOT the stored attribute, and the choice binder accumulates rather than replaces — so a plain push would leave a stale prior-turn color first in the list (chosen_color() is first-match). The write retain-drops the prior Color before pushing, so re-activating on a later turn with a new color replaces cleanly. The Colorless→White fallback converter (mana_payment.rs) is left untouched: it is load-bearing-defensive for the three Phyrexian-shard sites, where CR 107.4a makes Colorless unreachable. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): The Tomb of Aclazotz — cast-from-graveyard type-grant rider Closes the last gap on The Tomb of Aclazotz's "{T}: You may cast a creature spell from your graveyard this turn. If you do, it enters with a finality counter on it and is a Vampire in addition to its other types." The finality counter half was already wired via the ExileWithAltCost permission channel; this adds the parallel channel for the "is a Vampire in addition to its other types" type grant. - New Effect::AddPendingEntersModifications { modifications: Vec<ContinuousModification> } — the general "enters-with continuous modifications" carrier (CR 613 layer system), a categorical sibling of AddPendingETBCounters (CR 122 physical counters). The Vec<ContinuousModification> shape absorbs every future enters-with rider (added types, granted abilities, colors), so no third sibling is needed. The two channels stay separate because a counter is not a continuous modification. - Parser (oracle_effect/mod.rs): try_parse_cast_this_way_enters_rider lifts the former trailing-tail rejection (CR 205.1b) to accept "… and is a <type> in addition to its other types", parsing the tail via animation::parse_becomes_type_modifications (additive AddType/AddSubtype/ AddSupertype — retains printed types, CR 205.1b) and emitting the new effect as the counter rider's sub-ability. - Permission channel (mirrors enters_with_counter exactly): a new ExileWithAltCost.enters_with_modifications field (serde default + skip-if-empty), lifted from the rider at grant time and read back from the SELECTED permission at cast-finalize (CR 611.2c — no sibling-permission leak), applied to the cast object as a Duration::Permanent continuous effect (Layer 4, CR 613.1d). - #5072 read/write dual-walk: AddPendingEntersModifications is classified as a self-scoped SetMembership write (mirroring Effect::Animate/Endure's type-line writes) — NOT an ObjectCounters write; …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The /admin/* HTTP endpoints exposed full draft session state — including
player reconnect tokens, enabling session hijacking — and force-delete
with no authentication. Move them behind a PHASE_ADMIN_TOKEN bearer-token
middleware that fails closed (404) when no token is configured and uses a
constant-time comparison otherwise. The client-facing /p2p-draft-backup
routes are left open.
Add Dependabot (cargo, pnpm, npm, github-actions), a Security workflow
(cargo audit gate + reported npm/pnpm audit), and a CodeQL workflow for
the TypeScript surface.
https://claude.ai/code/session_01RBXLPHJG2BxD5tot74aExY