Skip to content

Zone-change pipeline + type dedup + parser grammar consolidation - #2824

Merged
matthewevans merged 55 commits into
mainfrom
ship/zone-pipeline-type-dedup
Jun 10, 2026
Merged

Zone-change pipeline + type dedup + parser grammar consolidation#2824
matthewevans merged 55 commits into
mainfrom
ship/zone-pipeline-type-dedup

Conversation

@matthewevans

Copy link
Copy Markdown
Member

Summary

Architectural remediation batch 2: the zone-change pipeline (single entry + proof-token delivery), the type-dedup batch, parser grammar consolidation, and the verbatim-cluster cleanup. 50 commits squashed; contributors should rebase after this lands.

Zone-change pipeline (Phases A–D + W1–W2)

  • Single entry: all zone moves route through move_object/execute_zone_movereplace_event consult → ApprovedZoneChange proof token → deliver + delivery tail (CR 614.1c entry counters/tap state, CR 306.5b/310.4b intrinsic loyalty, CR 701.24a library auto-shuffle).
  • Centralized park: replacement::park_waiting_for happens inside execute_zone_move — callers can no longer forget to park on NeedsChoice.
  • PendingBatchDeliveries (generalized from mill) with mods/attribution + typed BatchCompletion continuations (surveil keep-on-top, manifest-dread cleanup, rest piles).
  • Rules fixes found by review, each with fail-first tests: mill batch resumes across per-card replacement choices; non-destroy SBA deaths consult Moved redirects (CR 704.5g + 614.6); plain discard consults Moved redirects; self-ETB Moved replacements scoped to battlefield entry (18 sites stamped destination_zone); paused fizzle runs the resolution epilogue; stack-resident objects' own Moved redirects scanned on stack departure; mulligan shuffle-back no longer fires per-card auto-shuffles; debug zone changes are fully inert.
  • Exempt causes (mulligan CR 103.5, elimination CR 800.4a, casting CR 601.2a, debug) skip the consult but keep unconditional guards (CR 111.8 token cessation, B→B no-op).

Type dedup

  • Effect::AddCounter folded into Effect::PutCounter (serde alias kept).
  • sorcery_speed: bool deleted → ActivationRestriction::AsSorcery + is_sorcery_speed() (CR 602.5d), with deserialize migration for legacy payloads.
  • ActivationCadence folded into ActivationRestriction (Crew once_per_turn).

Parser consolidation

  • Three parallel duration grammars unified into oracle_nom/duration.rs (single authority).
  • parse_restriction_modes rewritten as negation × verb-phrase-list grammar (atoms compose; no permutation enumeration).
  • Verbatim sentence clusters in subject.rs/sequence.rs replaced with nom single-authority combinators.
  • Single authority for unparsed text: Effect::unimplemented(name, fragment) constructor, hand-built literals gated in CI.

CI

  • Parser-combinator gate extended (.rfind/.split/.splitn + long-literal equality); engine-authority and skill-doc gates added.
  • Coverage ratchet made informational on push-to-main (kept blocking for PRs/merge queue).

Verification

  • Full Tilt gate (clippy, test-engine, test-ai) green on each review round; six review rounds across implementation batches with an isolated opus reviewer.
  • cargo check --workspace --all-targets green in the ship worktree post-merge with origin/main.

🤖 Generated with Claude Code

…ntries

- 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
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.
- 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)
… 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).
…ay_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.
…racle_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.
…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.
…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.
… 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).
…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).
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.
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.
…n::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.
…R 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).
…ge 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.
…of 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).
…roof 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).
…peline 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).
…eview 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).
…eflight (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).
…field (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.
…ects 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).
…stead 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).
…d, 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).
…ound-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).
…eplacement (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).
…iew 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).
…peline (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).
…ch 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).
… 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).
… / 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).
…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).
…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.
…n-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.
…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...
… (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.
…k 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.
…e 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.
…g (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).
…ipeline (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).
…se 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).
…dence (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 preservation risk.

Documents the deferral at the move_object placement arm and at
reveal_until::shuffle_to_bottom (enriching the prior C5 deferral note with the
concrete destination-distribution evidence). No code change.
…per-card auto-shuffle (Phase D review fix)

The Phase D migration (92e2ad1) routed shuffle_hand_into_library's hand->
library returns through move_object WITHOUT a placement. A placement-less
Library-destination request runs the delivery tail, whose CR 701.24a
auto-shuffle arm fires PER CARD: a 7-card mulligan emitted seven ShuffledLibrary
player-action events (pre-pipeline count: zero — the mulligan's single real
shuffle is the event-less shuffle_vector that follows the loop) and consumed
seven extra full-library shuffles from the seeded RNG stream, diverging
same-seed games across versions. The commit's 'no behavior change' claim was
false on this path.

Route the shuffle-back requests through the library-placement arm via
.at_library_position(Bottom) — exactly like the two bottoming loops migrated in
the same commit. Insertion order is irrelevant because the explicit single
shuffle immediately follows. Comment corrected to record the landmine.

Fail-first evidence (test added before the fix, tilt test-engine run):
  thread 'game::mulligan::tests::mulligan_shuffle_back_emits_no_shuffled_library_events'
  panicked: assertion left == right failed ... left: 7, right: 0
Post-fix: 12094/12094 pass.

Pinning test: mulligan_shuffle_back_emits_no_shuffled_library_events — drives
start_mulligan + a Mulligan decision through handle_mulligan_decision and pins
the ShuffledLibrary player-action event count at 0 (the pre-W2 count).

CR grep (docs/MagicCompRules.txt):
  103.5. ... To take a mulligan, a player shuffles the cards in their hand back into their library ...
  701.24a To shuffle a library or a face-down pile of cards, randomize the cards within it so that no player knows their order.
…ail (Phase D review fix)

The Phase D migration (92e2ad1) routed DebugCommand moves through the exempt
path's seal+deliver, which still runs the full delivery tail. For a debug
battlefield staging that meant: CR 614.1c 'enters with an additional counter'
statics (Kalain class) minted counters onto debug-staged creatures,
pending_etb_counters from delayed triggers were applied and consumed, and the
CR 614.12a devour snapshot could be captured — contradicting the site comment
('matching the prior raw placement', true only of intrinsic seeding). The
discarded move_object result also meant an apply_etb_counters pause (counter-
doubling replacement choice class) could park a prompt mid-debug-action.

Adjudication: debug staging is FULLY inert. Debug exists for test-scenario
setup — operator intent is 'force the state'; minting statics-derived counters
onto staged objects surprises scenario authors and consuming pending_etb
entries mutates unrelated pending state. DebugCommand now routes through the
no-tail primitive zones::move_to_zone inside move_object's exempt branch (a
typed, single-chokepoint justification in zone_pipeline.rs). Every
unconditional guard still runs — CR 111.8 token, CR 614.1d ETB block, CR 400.7
cleanup, ZoneChanged emission — because those live in the primitive itself.
This restores exact pre-Phase-D behavior for all debug moves and makes
DebugCommand non-pausing by construction (always Done), so the discarded
result at the engine_debug call sites is safe — documented at each site.

The other exempt causes keep the tail: it is inert for their destinations
(pregame exile/hand have no tail arms, pregame library goes through the
placement arm post-2c669b3, elimination's battlefield departure wants the
mark_layers_full).

Regression test: debug_move_to_battlefield_skips_delivery_tail_counters —
stages a creature via DebugAction::MoveToZone with a pending ETB counter entry
queued; asserts the staged object has zero counters and the
pending_etb_counters entry is NOT consumed. Pre-fix the tail applied 2 counters
and consumed the entry (deliver_replaced_zone_change's pending-ETB block).
Post-fix: 12095/12095 pass.

CR grep (docs/MagicCompRules.txt):
  614.1c Effects that read '[This permanent] enters with . . . ,' 'As [this permanent] enters . . . ,' ...
  111.8 A token that has left the battlefield can't move to another zone ...
  400.7 An object that moves from one zone to another becomes a new object ...
@matthewevans
matthewevans enabled auto-merge June 10, 2026 13:46
…mmand in W3 deferral docs (Phase D review fix)

(a) ZoneChangeCause::is_exempt() is now an exhaustive match with no wildcard
fallback (house rule): adding a future ZoneChangeCause variant forces an
explicit consult/exempt decision at the chokepoint (with its CR citation in
the doc comment above), instead of silently inheriting a default. Non-exempt
arms (Effect / Cost / SpellResolutionDefault / StateBasedAction /
CommanderRuleReturn) are now spelled out.

(b) The W3 deferral docs (zone_pipeline.rs placement arm,
reveal_until::shuffle_to_bottom) now record the reproducible census command
beside the 25 Battlefield / 17 Graveyard / 2 Exile / 0 Library figures:

  rg -o 'destination_zone\(Zone::\w+\)' crates/engine/src | sort | uniq -c

with a note to re-run it before lifting the deferral. Output re-verified at
commit time: 25 Battlefield / 2 Exile / 17 Graveyard, zero Library.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the zone-change pipeline to route various moves (such as countered spells, milled cards, mass bounce, and state-based action departures) through the standard replacement pipeline, ensuring that redirects like Rest in Peace are correctly applied. It also unifies sorcery-speed timing representation under ActivationRestriction::AsSorcery, consolidates counter-placement effects into PutCounter, and updates the Crew keyword's once-per-turn restriction. Feedback highlights critical issues where early-returning on battlefield-entry choices (such as Clone copies) in reveal_until.rs and engine_resolution_choices.rs can permanently strand cards in temporary zones. Additionally, the reviewer noted a state corruption risk in zone_pipeline.rs regarding unhandled aura attachment choices and a parsing failure in subject.rs when handling restriction clauses with trailing periods.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +161 to 163
ZoneMoveResult::NeedsChoice(_) | ZoneMoveResult::NeedsAuraAttachmentChoice => {
return Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Potential card stranding on battlefield-entry choice.

Why it matters: If the revealed card has an as-enters replacement effect (such as Clone's copy choice, or choosing a color/type/Devour), move_object will return NeedsChoice. Early-returning Ok(()) here bypasses the subsequent move_rest call, permanently stranding the remaining revealed cards in the temporary zone instead of returning them to the library.

Suggested fix: Defer the rest-pile movement/cleanup using a BatchCompletion continuation or similar mechanism, rather than a bare early return.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 89de174. The kept card's battlefield entry genuinely can pause — an Aura among the revealed cards (Polymorph/Proteus-class can hit one) surfaces a CR 303.4f host choice, and competing ETB replacements surface a CR 616.1 prompt — and the early return stranded the misses. The rest-pile move + marker cleanup now defer onto the parked batch tail via a typed BatchCompletion::RevealRestPile, mirroring the surveil/manifest-dread rest-pile pattern, so it runs exactly once after the entry resolves.

Comment on lines +526 to +531
crate::game::zone_pipeline::ZoneMoveResult::NeedsChoice(_)
| crate::game::zone_pipeline::ZoneMoveResult::NeedsAuraAttachmentChoice => {
return Ok(ResolutionChoiceOutcome::WaitingFor(
state.waiting_for.clone(),
));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Potential card stranding on battlefield-entry choice in RevealUntilKeptChoice.

Why it matters: If the accepted card has an as-enters replacement effect (like Clone), move_object will return NeedsChoice. Early-returning here bypasses the move_rest call at the end of the block, permanently stranding the misses (the rest of the revealed cards) in the temporary zone instead of putting them into the decline/rest destination.

Suggested fix: Defer the rest-pile movement/cleanup using a BatchCompletion continuation or similar mechanism, rather than a bare early return.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same root cause as the reveal_until::resolve path — confirmed and fixed identically in 89de174. On an accept-to-battlefield pause we now stash a RevealRestPile completion carrying the misses and their markers; the drain moves them after the kept card's choice resolves instead of stranding them.

Comment on lines +1294 to +1299
crate::game::zone_pipeline::ZoneMoveResult::NeedsChoice(_)
| crate::game::zone_pipeline::ZoneMoveResult::NeedsAuraAttachmentChoice => {
return Ok(ResolutionChoiceOutcome::WaitingFor(
state.waiting_for.clone(),
));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Potential card stranding on battlefield-entry choice in SelectCards (dig).

Why it matters: If a kept card has an as-enters replacement effect (like Clone), move_object will return NeedsChoice. Early-returning here bypasses the subsequent movement of the unkept cards to the rest destination, permanently stranding them in the temporary zone.

Suggested fix: Defer the rest-pile movement/cleanup using a BatchCompletion continuation or similar mechanism, rather than a bare early return.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 89de174, with a discriminating test (dig_rest_pile_not_stranded_when_kept_aura_pauses_on_attachment_choice). A dig that keeps an Aura onto the battlefield with two legal hosts pauses on the CR 303.4f attachment choice; pre-fix the unkept cards stayed in the library (left: Library, right: Graveyard). The fix defers the rest-pile move plus the dig-specific tracked-set publish and continuation wiring onto the parked batch tail.

Comment on lines +704 to +724
ZoneMoveResult::NeedsChoice(_) => {
// CR 616.1: `move_object` already parked the surfaced prompt
// (centralized park at its `replace_event` NeedsChoice arm);
// stash the rest of the batch so no object strands. The paused
// object rides in `state.pending_replacement` and is delivered
// by the resume path.
stash_batch_tail(state, queue.collect(), destination);
return BatchMoveResult::NeedsChoice;
}
ZoneMoveResult::NeedsAuraAttachmentChoice => {
// CR 303.4f: an aura-host choice flows through
// `WaitingFor::ReturnAsAuraTarget`, not the replacement-choice
// resume path, so `drain_pending_batch_deliveries` (which only
// runs from `handle_replacement_choice`) would not fire here.
// No batch flow targets a battlefield aura entry today (mill
// destinations are graveyard/exile/hand; mass bounce returns to
// hand/library), so this is unreachable; stop and stash the
// tail so a future battlefield-entry batch does not silently
// drop the rest of the batch.
//
// NOTE: this is NOT a loud failure. The engine_replacement

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Silent state corruption risk on NeedsAuraAttachmentChoice.

Why it matters: If a batch flow ever triggers NeedsAuraAttachmentChoice, the tail is stashed in pending_batch_deliveries, but because aura-host choices do not route through the replacement-choice resume path, drain_pending_batch_deliveries will not be called. This leaves a stale tail that will be silently and incorrectly drained by the next unrelated replacement-choice resume, causing severe state corruption.

Suggested fix: Instead of silently stashing the tail and returning NeedsChoice, trigger a loud failure (e.g., panic! or unreachable!) or handle the aura-target resolution path explicitly to prevent silent corruption.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real hole, now closed in 89de174. The ReturnAsAuraTarget resume path drained pending_continuation but not pending_batch_deliveries — fine for mill (Battlefield isn't a mill destination) but live for the reveal/dig kept-Aura sites. Both branches of that handler now drain the parked batch tail, which is what makes the rest-pile deferral actually fire on the aura path (verified by the new dig test, which pauses precisely on the CR 303.4f choice).

Comment on lines 2730 to 2759
pub(crate) fn parse_restriction_modes(lower: &str) -> Option<Vec<StaticMode>> {
// CR 701.21: "~ can't be sacrificed" — prohibition on sacrifice.
if lower == "can't be sacrificed" || lower == "cannot be sacrificed" {
return Some(vec![StaticMode::Other("CantBeSacrificed".to_string())]);
}
// CR 702.5: "~ can't be enchanted [by other auras]" — aura attachment prohibition.
if lower == "can't be enchanted"
|| lower == "cannot be enchanted"
|| lower == "can't be enchanted by other auras"
|| lower == "cannot be enchanted by other auras"
// Negation prefix × verb-phrase-list grammar (CLAUDE.md "Compose nom
// combinators, don't enumerate permutations"): "can't"/"cannot" applies
// once and distributes over a comma/or-separated list of
// [`parse_restriction_list_atom`]s, covering every compound wording
// without enumerating the cross-product. Parameterized forms that carry
// a trailing filter ("… except by <filter>", "… by <filter>") fail the
// all_consuming list and fall through to their dedicated arms below.
if let Ok((_, atom_modes)) = all_consuming(preceded(
(
alt((tag::<_, _, OracleError<'_>>("can't"), tag("cannot"))),
tag(" "),
),
separated_list1(
alt((tag(", or "), tag(", "), tag(" or "))),
parse_restriction_list_atom,
),
))
.parse(lower)
{
return Some(vec![StaticMode::Other("CantBeEnchanted".to_string())]);
}
// CR 702.6: "~ can't be equipped" — equipment attachment prohibition.
if lower == "can't be equipped" || lower == "cannot be equipped" {
return Some(vec![StaticMode::Other("CantBeEquipped".to_string())]);
}
// CR 701.3 + CR 702.5 + CR 702.6: "can't be equipped or enchanted" compound —
// binds to both attach-type prohibitions. Fortifications are excluded by the
// Oracle wording, so we do NOT emit CantBeAttached (which is a superset).
if lower == "can't be equipped or enchanted" || lower == "cannot be equipped or enchanted" {
return Some(vec![
StaticMode::Other("CantBeEquipped".to_string()),
StaticMode::Other("CantBeEnchanted".to_string()),
]);
}
// CR 701.27: "~ can't transform" — prohibition on transform (e.g., Immerwolf).
if lower == "can't transform" || lower == "cannot transform" {
return Some(vec![StaticMode::Other("CantTransform".to_string())]);
return Some(atom_modes.concat());
}
// CR 701.19c: "~ can't be regenerated" — marks the subject so regeneration
// shields are not applied. Backstop for the "cannot" phrasing and any caller
// that routes through the generic " can't " / " cannot " split before
// reaching the dedicated arm in `try_parse_subject_restriction_clause`.
// Kept outside the atom list: it tolerates a trailing period.
if parse_cant_be_regenerated_predicate(lower.trim()).is_ok() {
return Some(vec![StaticMode::CantBeRegenerated]);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Parsing failure on restriction clauses with trailing periods.

Why it matters: all_consuming expects the entire input to be consumed. If lower contains a trailing period (e.g., "can't attack or block."), parse_restriction_list_atom will parse "block", leaving the trailing period unconsumed, which causes all_consuming to fail. Trailing periods are common in Oracle text clauses.

Suggested fix: Trim trailing periods and whitespace from lower before parsing, or allow an optional trailing period in the parser.

pub(crate) fn parse_restriction_modes(lower: &str) -> Option<Vec<StaticMode>> {
    // Negation prefix x verb-phrase-list grammar (CLAUDE.md "Compose nom
    // combinators, don't enumerate permutations"): "can't"/"cannot" applies
    // once and distributes over a comma/or-separated list of
    // parse_restriction_list_atom, covering every compound wording
    // without enumerating the cross-product. Parameterized forms that carry
    // a trailing filter ("... except by <filter>", "... by <filter>") fail the
    // all_consuming list and fall through to their dedicated arms below.
    let cleaned = lower.trim().trim_end_matches('.');
    if let Ok((_, atom_modes)) = all_consuming(preceded(
        (
            alt((tag::<_, _, OracleError<'_>>("can't"), tag("cannot"))),
            tag(" "),
        ),
        separated_list1(
            alt((tag(", or "), tag(", "), tag(" or "))),
            parse_restriction_list_atom,
        ),
    ))
    .parse(cleaned)
    {
        return Some(atom_modes.concat());
    }
    // CR 701.19c: "~ can't be regenerated" - marks the subject so regeneration
    // shields are not applied. Backstop for the "cannot" phrasing and any caller
    // that routes through the generic " can't " / " cannot " split before
    // reaching the dedicated arm in try_parse_subject_restriction_clause.
    // Kept outside the atom list: it tolerates a trailing period.
    if parse_cant_be_regenerated_predicate(cleaned).is_ok() {
        return Some(vec![StaticMode::CantBeRegenerated]);
    }
References
  1. Avoid verbatim string equality for parsing Oracle phrases as it bypasses the robust nom-based parser and creates fragile matches. Instead, decompose compound phrases into modular, reusable parsers for constituent parts and compose them using idiomatic combinator aggregates to prevent combinatorial explosion and improve maintainability.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed real and fixed in 234a87f. The terminal period does reach parse_restriction_modestry_parse_subject_restriction_clause's predicate keeps it when no trailing duration strips it — so "can't attack or block." fell through all_consuming and the restriction was silently dropped. (The pre-rewrite == matching had the same blind spot, so this was latent, not a regression.) Fixed in the combinator with terminated(list, opt(tag("."))) before eof rather than trimming the string, matching the can't be regenerated arm. Two discriminating tests added.

@matthewevans
matthewevans added this pull request to the merge queue Jun 10, 2026
@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request Jun 10, 2026
…n as-enters choice (CR 303.4f / 616.1)

The reveal_until::resolve, RevealUntilKeptChoice, and DigChoice (SelectCards)
handlers each route the kept/accepted card onto the battlefield through
zone_pipeline::move_object, then bailed with an early `return` on a
NeedsChoice / NeedsAuraAttachmentChoice pause. The rest-pile move (move_rest /
route_rest_partition) ran AFTER that early return, so when the kept card's
battlefield entry paused on an as-enters choice (CR 303.4f aura host pick when
the kept Aura has >1 legal host, or a CR 616.1 ordering prompt between two
enters-with-counter replacements) the unkept "rest pile" stranded in the
library — never moved, markers never cleared.

Defer the rest-pile move + reveal-marker cleanup (and, for dig, the tracked-set
publish + continuation target wiring) onto the parked batch tail via a new
typed BatchCompletion::RevealRestPile, mirroring the surveil/manifest-dread
rest-pile pattern (BatchCompletion + PendingBatchDeliveries, commit 4adcca3).
A single-object move_object pause stashes no batch tail, so
zone_pipeline::defer_completion_on_pause creates an empty-remaining record
carrying only the completion; the drain delivers nothing and runs the
completion once the kept card's entry resolves.

The replacement-choice resume path already drained pending_batch_deliveries.
The aura-attachment resume (WaitingFor::ReturnAsAuraTarget in engine.rs) did
NOT — it was the only drain site missing for an NeedsAuraAttachmentChoice
pause, so add the drain to both of its resume branches (Gemini PR #2824 review
comment, and the hole documented for mill in d55e500 — dead code there since
Battlefield is not a Mill destination, but live for these reveal/dig sites
whose kept card enters the battlefield and may be an Aura).

CR grep (docs/MagicCompRules.txt):
  303.4f If an Aura is entering the battlefield ... that player chooses what it will enchant as the Aura enters the battlefield.
  616.1. If two or more replacement and/or prevention effects are attempting to modify the way an event affects an object or player, ... chooses one to apply ...
  603.10a Some zone-change triggers look back in time. ...
  701.20b Revealing a card doesn't cause it to leave the zone it's in.

Discriminating test (dig_rest_pile_stranding_on_etb_pause.rs):
  dig_rest_pile_not_stranded_when_kept_aura_pauses_on_attachment_choice —
  a dig keeps an Aura to the battlefield with two legal hosts (CR 303.4f
  attachment choice) and the rest pile bound for the graveyard. Pre-fix the
  unkept cards stayed in Library (asserted left: Library, right: Graveyard);
  post-fix they reach the graveyard after the attachment choice is answered.
… (CR 508.1d / 509.1a)

parse_restriction_modes wraps its negation × verb-phrase-list grammar in
all_consuming, which requires the input fully consumed. A static line's
terminal period reaches this function unstripped — try_parse_subject_restriction_clause
keeps the predicate's period when no trailing duration phrase strips it
(strip_trailing_duration_no_match_preserves_period) — so "can't attack or
block." failed the all_consuming list and fell through, dropping the
restriction entirely. The dedicated "can't be regenerated" arm already
tolerates a trailing period; the compound atom-list did not.

Absorb the optional trailing "." inside the combinator via
terminated(list, opt(tag("."))) before all_consuming's eof, rather than
trimming the input string (CLAUDE.md combinator mandate). This is parity-
restoring, not a behavior change for period-free input.

CR grep (docs/MagicCompRules.txt):
  508.1d The active player ... can't attack ...
  509.1a ... a creature can't block ...

Discriminating tests (oracle_effect/subject.rs):
  parse_restriction_modes_tolerates_trailing_period — the unit function on
    "can't attack or block." (pre-fix: None; post-fix: [CantAttack, CantBlock]).
  cant_attack_or_block_with_trailing_period_builds_both_modes — the production
    path try_parse_subject_restriction_clause on the full line with a period
    (pre-fix: failed to parse; post-fix: two StaticMode entries).
@matthewevans
matthewevans enabled auto-merge June 10, 2026 14:45
@matthewevans
matthewevans disabled auto-merge June 10, 2026 14:46
@matthewevans
matthewevans merged commit 6096258 into main Jun 10, 2026
10 checks passed
@matthewevans
matthewevans deleted the ship/zone-pipeline-type-dedup branch June 10, 2026 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant