Skip to content

fix(engine): unearth's leave-battlefield exile replacement must survive layer resets (Rotting Rats + Sacrifice) - #6538

Merged
matthewevans merged 1 commit into
phase-rs:mainfrom
michiot05:fix/unearth-exile-replacement-5976
Jul 24, 2026
Merged

fix(engine): unearth's leave-battlefield exile replacement must survive layer resets (Rotting Rats + Sacrifice)#6538
matthewevans merged 1 commit into
phase-rs:mainfrom
michiot05:fix/unearth-exile-replacement-5976

Conversation

@michiot05

@michiot05 michiot05 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Tier: Frontier
Model: claude-opus-4-8

Closes #5976.

Summary

Rotting Rats returned via Unearth and then sacrificed to Sacrifice went to the graveyard instead of exile. Unearth's rider — "Exile it at the beginning of the next end step or if it would leave the battlefield" (CR 702.84a) — has two halves, and only the end-step delayed trigger worked; the "if it would leave the battlefield, exile it instead" replacement did nothing on a sacrifice, destroy, or bounce.

Root cause is not the sacrifice path (that correctly routes through sacrifice_permanentreplace_event, and the matcher accepts the rider). The rider was installed onto the returned creature's live replacement_definitions only. seed_live_characteristics_from_base reseeds live characteristics from base on every layer pass (CR 613.1) — and unearth's return grants haste, forcing an immediate layer evaluation — so the rider was wiped before any real leave event ever reached it. The end-step half survived only because it is a state-hosted delayed trigger, not an object-hosted replacement.

Class-level fix, not a Rotting-Rats special case: a typed RestrictionExpiry::UntilHostLeavesPlay, stamped on the rider at both build sites (the unearth synthesizer and the shared try_parse_leave_battlefield_exile_replacement parser builder that also handles the Whip-of-Erebos rider class). The stamp threads through the three existing runtime-rider seams that the turn-bound die-exile rider (Torch the Tower) already established:

  1. Base-install (add_target_replacement.rs) — the rider persists in base_replacement_definitions, so it survives every CR 613.1 reseed.
  2. Non-copiable (printed_cards.rs, CR 707.2) — token/merge/augment copies never inherit it (mana isn't copied; a copy isn't the cast object).
  3. Host-leave prune (layers.rs, CR 400.7) — on the host's battlefield exit (including the exile redirect itself) the rider is dropped from base+live, so a re-entered same-ObjectId object is a new object and is not wrongly re-exiled.

Because the rider is bound to the object (valid_card: SelfRef), it survives control theft — a stolen unearthed creature sacrificed by its new controller is still exiled.

Implementation method (required)

Method: /engine-implementer — plan → /review-engine-plan (1 round; findings integrated: corrected class inventory, verbatim-card-text discipline, prune reach-guard, variant-doc scoping) → implement → verification pass (one test-harness foot-gun fixed, no production defect) → /review-impl (CLEAN, zero findings) → commit → final read-only /review-impl: PASS head=a65dd1a2229f2c231fab50579e346df2901e2239. Each step in a fresh agent context.

Gate A

./scripts/check-parser-combinators.shGate A PASS head=a65dd1a2229f2c231fab50579e346df2901e2239 (Gate G PASS). The parser change is a single .expiry(...) stamp on an already-built ReplacementDefinition — no string probes, no nom-chain change.

Anchored on

  • crates/engine/src/database/unearth.rs (leaves_battlefield_exile_step) — the synthesized unearth rider, now stamped; and crates/engine/src/game/layers.rs:1692 (seed_live_characteristics_from_base) — the CR 613.1 reseed that wiped the live-only rider (the root cause).
  • The turn-bound die-exile rider precedent (Torch the Tower, EndOfTurn expiry) — the exact three-seam durable-runtime-rider pattern this change mirrors: is_runtime_target_die_exile_replacement (printed_cards.rs) + install_to_base (add_target_replacement.rs) + the host-left prune (layers.rs::prune_controller_controls_source_on_leave). The new predicate is_runtime_host_lifetime_replacement is the object-lifetime sibling.
  • crates/engine/src/types/ability.rsDuration::UntilHostLeavesPlay (the existing object-lifetime vocabulary) established that host-bound and turn-bound lifetimes coexist as distinct categorical members; the new RestrictionExpiry variant is the replacement-expiry counterpart.

Verification

  • cargo fmt --all clean; cargo clippy -p engine --tests 0 warnings.
  • New integration file (crates/engine/tests/integration/issue_5976_unearth_leave_redirect.rs, registered in main.rs) — 8 rows, each driving the real pipeline (unearth activate/resolve → a genuine layer pass → real leave event → zone assert, with PermanentSacrificed/damage/priority reach guards): the reported additional-cost sacrifice (verbatim Sacrifice card) → exile; ability-cost sacrifice outlet → exile; destroy → exile; bounce → exile (any leave event, CR 702.84a); non-unearthed sibling → graveyard; host-exit prune with no revival on re-entry; control-theft object-binding; and the real parser-rider card Gruesome Encore (verbatim) → bounce → exile.
  • Unit tests: unearth base-install + reseed-survival; the two printed_cards predicate tests (non-copiable exclusion; and the Personal Decoy printed-static shape — no expiry stamp — staying copiable, the negative that proves the detector keys on the stamp, not the redirect shape); parser expiry-stamp assertion. All green.
  • Regression sweep: cargo test -p engine --lib -- replacement layer copiable torch_the_tower ninjutsu die_exile2398 passed, 0 failed (Torch-the-Tower EndOfTurn die-exile, copy/merge/augment, layers, ninjutsu all intact).
  • Fails before, passes after (tests written first): with the base-install stamp reverted, R1/R3/R4/R7/R8/R9 flip to Graveyard/Hand (left: Graveyard, right: Exile) while the non-unearthed sibling R5 stays green; restored → all pass.
  • CR annotations: every number grep-verified against docs/MagicCompRules.txt — CR 702.84a, 400.7, 613.1, 614.1a, 707.2, 611.2b.

Claimed parse impact

Regenerated card-data.json and isolated the change from the pre-existing Duration::UntilHostLeavesPlay namesake (288 pre-existing duration occurrences on the unrelated "exile until ~ leaves" family, untouched): exactly 64 cards gain an additive expiry: UntilHostLeavesPlay field on their existing leave-battlefield exile replacement — the complete class (~58 unearth cards: Rotting Rats, Hellspark Elemental, Fatestitcher, the Scrapwork/Dregscape cycle, Sedraxis Specter, … + the 6–7 parser-rider cards: Whip of Erebos, Gruesome Encore, From the Catacombs, Isareth the Awakener, Kheru Lich Lord, Moira and Teshar, Dreams of the Dead). No card gains or loses support — the replacement already existed; it now carries the host-lifetime stamp and is made durable. The CI parse-diff artifact should report these 64 signature changes.

Scope Expansion

None.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved Unearth and similar effects that redirect a creature’s departure to exile.
    • Replacement effects now remain active only while hosted by the battlefield object.
    • Prevented replacement effects from incorrectly returning when an object leaves and re-enters play.
    • Ensured these effects are not copied to other objects and continue working after layer updates.
    • Added coverage for sacrifice, destruction, bouncing, controller changes, and parsed card effects.

…ve layer resets (Rotting Rats + Sacrifice)

Unearth installs a rider — "if it would leave the battlefield, exile it
instead of putting it anywhere else" (CR 702.84a) — on the returned
creature, but it was pushed to the object's LIVE replacement set only.
Every layer pass reseeds live characteristics from base (CR 613.1), so
the rider was wiped before any real leave event; sacrificing, destroying,
or bouncing an unearthed creature sent it to the graveyard instead of
exile. Only the end-step delayed-trigger half worked (it is state-hosted,
not object-hosted).

Adds a typed RestrictionExpiry::UntilHostLeavesPlay stamped on the rider
at both build sites (unearth synthesis + the Whip-of-Erebos-class parser
builder). The stamp (1) forces base-install so the rider survives CR
613.1 reseeds, (2) marks the def non-copiable (CR 707.2 — token copies
never inherit it), and (3) prunes it base+live when the host leaves the
battlefield (CR 400.7 — a re-entered same-ObjectId object is a new object
and must not be re-exiled). Object-bound, so it survives control theft.
Fixes ~58 unearth cards + 6 leave-battlefield-rider cards
(Whip of Erebos, Gruesome Encore, Isareth, Kheru Lich Lord, Moira and
Teshar, From the Catacombs). Personal Decoy's printed static (no expiry
stamp) stays copiable — the detectors key on the stamp, not the shape.

Closes phase-rs#5976

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@michiot05
michiot05 requested a review from matthewevans as a code owner July 23, 2026 06:57
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Unearth and parsed replacement riders now use UntilHostLeavesPlay, remain through layer reseeding, are non-copiable, and are pruned when their host leaves play. Unit and integration tests cover sacrifice, destruction, bounce, re-entry, controller changes, and parsed riders.

Changes

Unearth host-lifetime replacement flow

Layer / File(s) Summary
Replacement expiry and classification
crates/engine/src/types/ability.rs, crates/engine/src/database/unearth.rs, crates/engine/src/parser/oracle_effect/*, crates/engine/src/game/printed_cards.rs
Adds UntilHostLeavesPlay, stamps Unearth and parsed riders with it, and classifies those riders as runtime non-copiable replacements with unit coverage.
Installation and host-exit pruning
crates/engine/src/game/effects/add_target_replacement.rs, crates/engine/src/game/layers.rs, crates/engine/src/database/unearth_tests.rs
Persists host-lifetime riders in base replacement definitions, preserves them through layer evaluation, and removes them when the host leaves play.
Unearth and parsed-rider scenarios
crates/engine/tests/integration/issue_5976_unearth_leave_redirect.rs, crates/engine/tests/integration/main.rs
Adds integration coverage for leave-the-battlefield redirects, host re-entry pruning, controller changes, direct battlefield entry, and parser-installed riders.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Player
  participant Unearth
  participant LayerEvaluation
  participant ReplacementSystem
  participant ZoneChange
  Player->>Unearth: activate Unearth
  Unearth->>ReplacementSystem: install host-lifetime exile rider
  Unearth->>LayerEvaluation: return permanent and reseed layers
  LayerEvaluation-->>ReplacementSystem: retain live rider
  Player->>ZoneChange: sacrifice, destroy, or bounce permanent
  ZoneChange->>ReplacementSystem: evaluate leave-the-battlefield event
  ReplacementSystem-->>ZoneChange: redirect permanent to exile
Loading

Suggested labels: bug

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the Unearth leave-battlefield exile fix and matches the main change, though it is a bit verbose.
Linked Issues check ✅ Passed The Unearth replacement now survives layer resets and correctly exiles the creature on sacrifice, matching #5976.
Out of Scope Changes check ✅ Passed The parser, layer, type, and test changes all support the Unearth expiry fix and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: 1 blocker — request changes.

🔴 Blocker

[HIGH] Preserve the enclosing duration for generic leave-battlefield exile riders. Evidence: crates/engine/src/parser/oracle_effect/mod.rs:1768-1796 accepts the generic rider and unconditionally stamps UntilHostLeavesPlay; crates/engine/src/game/effects/add_target_replacement.rs:25-31 derives an ability duration only when expiry is absent. The parser itself routes a leading duration through with_clause_duration at oracle_effect/mod.rs:8224-8225. Scryfall Oracle text for Elemental Expressionist is: “Until end of turn, it gains "If this creature would leave the battlefield, exile it instead of putting it anywhere else" …”. CR 611.2a says a continuous effect lasts as long as its stated duration, and CR 514.2 ends all until-end-of-turn effects during cleanup. Why it matters: that temporary rider is retained in base state and continues to redirect a later zone change after cleanup, producing a wrong game result. Suggested fix: make the resolver honor an enclosing duration for this rider and use host-lifetime expiry only when no enclosing duration applies; add a runtime regression that keeps the Expressionist target through cleanup, then moves it.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/engine/tests/integration/issue_5976_unearth_leave_redirect.rs (1)

383-444: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider adding a copy/token integration scenario.

The PR objective explicitly calls out preventing the rider from being "copied by tokens, merges, or augment," but this file's rows (R1–R9) cover sacrifice/destroy/bounce/control-change/parsed-rider — none drive a real token-copy or clone effect on the unearthed creature through the full pipeline. The unit test in printed_cards.rs checks intrinsic_copiable_values directly, which is the right seam, but a full-pipeline row here (e.g. clone the unearthed rats mid-game, then destroy the token and assert it lands in the graveyard, not exile) would close the gap between "the values function excludes it" and "the actual copy effect never re-attaches it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/issue_5976_unearth_leave_redirect.rs` around
lines 383 - 444, Add a full-pipeline integration scenario alongside
r7_host_exit_prunes_rider_no_revival_on_reentry that copies the unearthed rats
during the game using a real token or clone effect, then destroys the resulting
copy and asserts it goes to the graveyard rather than exile. Exercise the normal
copy-resolution path instead of directly testing intrinsic_copiable_values, and
ensure the scenario verifies the host-lifetime rider is not attached to the
copied object.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/parser/oracle_effect/tests.rs`:
- Around line 39299-39305: The comment above the assertion incorrectly cites CR
400.7 for the Unearth leaves-battlefield rider; update it to cite CR 702.84a and
describe that rule as covering the exile-instead rider, while retaining
base-installed, non-copiable, and host-exit behavior as engine semantics rather
than rule-citation claims.

---

Nitpick comments:
In `@crates/engine/tests/integration/issue_5976_unearth_leave_redirect.rs`:
- Around line 383-444: Add a full-pipeline integration scenario alongside
r7_host_exit_prunes_rider_no_revival_on_reentry that copies the unearthed rats
during the game using a real token or clone effect, then destroys the resulting
copy and asserts it goes to the graveyard rather than exile. Exercise the normal
copy-resolution path instead of directly testing intrinsic_copiable_values, and
ensure the scenario verifies the host-lifetime rider is not attached to the
copied object.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ef4a0197-0e66-4355-bee0-385e4c233866

📥 Commits

Reviewing files that changed from the base of the PR and between ebd8844 and a65dd1a.

📒 Files selected for processing (10)
  • crates/engine/src/database/unearth.rs
  • crates/engine/src/database/unearth_tests.rs
  • crates/engine/src/game/effects/add_target_replacement.rs
  • crates/engine/src/game/layers.rs
  • crates/engine/src/game/printed_cards.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/tests/integration/issue_5976_unearth_leave_redirect.rs
  • crates/engine/tests/integration/main.rs

Comment on lines +39299 to +39305
// CR 400.7 (issue #5976): the parsed rider is stamped with the
// host-lifetime expiry so it is base-installed, non-copiable, and
// pruned on host exit.
assert_eq!(
replacement.expiry,
Some(crate::types::ability::RestrictionExpiry::UntilHostLeavesPlay),
"the parsed leaves-battlefield rider must carry UntilHostLeavesPlay"

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A4 -B2 '^(400\.7|702\.84a)\b' docs/MagicCompRules.txt

Repository: phase-rs/phase

Length of output: 2040


🌐 Web query:

Magic Comprehensive Rules Unearth 702.84a 400.7 leave-the-battlefield replacement effect

💡 Result:

In Magic: The Gathering, the interaction between the keyword ability Unearth (rule 702.84a) and the general zone-change rule (rule 400.7) creates a scenario where an object's identity is reset upon moving zones [1][2]. Rule 702.84a defines Unearth as: "[Cost]: Return this card from your graveyard to the battlefield. It gains haste. Exile it at the beginning of the next end step. If it would leave the battlefield, exile it instead of putting it anywhere else. Activate only as a sorcery" [1][3]. Rule 400.7 states that an object that moves from one zone to another becomes a new object with no memory of, or relation to, its previous existence [2][4]. When a creature returned to the battlefield via Unearth leaves the battlefield—for example, via a "flicker" effect that exiles it and then returns it to the battlefield—the following occurs: 1. The creature is moved to a new zone (e.g., exile) [1]. 2. Because the creature has left the battlefield, the Unearth replacement effect attempts to exile it instead of putting it anywhere else [1][3]. However, if the effect causing it to leave is already moving it to the exile zone, that effect succeeds [1]. 3. Upon entering the new zone (exile), the creature becomes a new object with no relation to its previous existence on the battlefield, per rule 400.7 [2][5]. 4. Because the new object is not the same object that was originally brought back by the Unearth ability, the original Unearth effect (both the delayed trigger that would exile it at the end step and the replacement effect that would exile it if it left the battlefield) no longer tracks or applies to this new object [1][5]. Consequently, if a creature unearthed this way is "flickered" (exiled and returned), it returns to the battlefield as a new permanent that is not subject to the original Unearth restrictions [1][5].

Citations:


Correct the Unearth CR citation. CR 702.84a covers the “exile it instead of putting it anywhere else” rider; CR 400.7 is only the general zone-change identity rule. Keep the base-installed / non-copiable / host-exit behavior as engine semantics, not as the rule citation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_effect/tests.rs` around lines 39299 - 39305,
The comment above the assertion incorrectly cites CR 400.7 for the Unearth
leaves-battlefield rider; update it to cite CR 702.84a and describe that rule as
covering the exile-instead rider, while retaining base-installed, non-copiable,
and host-exit behavior as engine semantics rather than rule-citation claims.

Source: Path instructions

@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR · 64 card(s), 1 signature(s) (baseline: main ebd88444882f)

🟡 Modified fields (1 signature)

  • 64 cards · 🔄 ability/AddTargetReplacement · changed field expiry: UntilHostLeavesPlay
    • Affected (first 3): Anathemancer, Archfiend of Sorrows, Artificer's Dragon (+61 more)

@michiot05

Copy link
Copy Markdown
Contributor Author

Thanks — CR 611.2a / 514.2 are exactly the right lens, so I traced Elemental Expressionist's data flow end-to-end before touching the stamp, and I think the temporary-rider path doesn't actually reach this PR's code. Laying out what I found in case I've missed a path you're seeing:

Expressionist never reaches the stamped replacement. Its "Until end of turn, it gains \"If this creature would leave the battlefield, exile it instead…\"" is a quoted grant, so it routes through oracle_static::classify_quoted_innerContinuousModification::GrantAbility, not through try_parse_leave_battlefield_exile_replacement. oracle_classifier.rs:563-570 deliberately sends granted "would leave the battlefield" text to the static parser ahead of the replacement fallback. Its actual parse in card-data.json is a Continuous static (duration: UntilEndOfTurn) carrying GrantAbility + GrantTriggerno AddTargetReplacement, no UntilHostLeavesPlay anywhere in the entry (it also still carries a SwallowedClause/Replacement_Instead warning). So it never hits mod.rs:1796 and never flows through replacement_with_ability_expiry, and the is_none() guard you cited is never consulted for it.

The stamped path is single-caller and only reanimation riders reach it. try_parse_leave_battlefield_exile_replacement is private with one production caller (mod.rs:7650). Corpus-wide, 15 cards carry the rider text; the 7 that reach the stamp are all standalone reanimation riders (Whip of Erebos, Gruesome Encore, Kheru Lich Lord, Moira and Teshar, From the Catacombs, Isareth, Dreams of the Dead) — the "next end step" ones use a delayed trigger, not a duration on the rider. Of the 64 cards that carry an AddTargetReplacement + UntilHostLeavesPlay in card-data.json, 57 are Duration::Permanent and 7 are None — zero are temporary. Since expiry_from_duration maps both Permanent and absent to None, the conditional design you describe ("host-lifetime only when no enclosing duration applies") derives host-lifetime for all 64 — behaviorally identical to the current stamp, and there's no card left to exercise the enclosing-duration branch.

Expressionist is genuinely broken — but by a separate, pre-existing gap in the opposite direction. Its granted rider parses to an inert GrantAbility (a bare ChangeZone→Exile spell body pushed into obj.abilities at layers.rs:6595, never into replacement_definitions), so today it redirects nothing — an under-implementation, not the post-cleanup over-retention the stamp would cause. Fixing it correctly is a granted, duration-bound replacement ability in the quoted-grant classifier (so the redirect fires within the turn and lapses at cleanup via the existing transient path) — a distinct change from this PR's host-lifetime stamp on standalone riders. I'd be glad to file that as its own issue and take it next.

Given that, adding the conditional-duration branch here would be a guard for a state no current card reaches, which I didn't want to slip in against the no-speculative-guards guidance — but I'm happy to add it as a forward-looking correctness measure if you'd prefer it in explicitly; it's a one-commit no-op for the 64. And if you're seeing Expressionist actually redirect to exile after cleanup in play, that contradicts what I'm finding (the grant looks inert), so please share the repro and I'll dig back in.

Head is unchanged at a65dd1a (Gate A + G PASS, clippy clean, the 8 integration rows + regression sweep green). Model: claude-opus-4-8

@matthewevans

Copy link
Copy Markdown
Member

Verdict: blocker withdrawn — your pushback is correct, and I verified it independently. Two LOW nits remain, neither blocking.

Thank you for the trace. I re-derived every step of your argument from the corpus rather than taking the numbers on trust, and it holds on all three legs. My changes-request was wrong and I'm retracting it.

✅ Clean — the duration-lifetime blocker does not survive

1. Elemental Expressionist never reaches the stamped path — confirmed. I pulled its Oracle text verbatim from Scryfall:

Magecraft — Whenever you cast or copy an instant or sorcery spell, choose target creature you control. Until end of turn, it gains "If this creature would leave the battlefield, exile it instead of putting it anywhere else" and "When this creature is put into exile, create a 4/4 blue and red Elemental creature token."

and then read its actual entry in card-data.json:

has AddTargetReplacement: False
has UntilHostLeavesPlay:  False
has GrantAbility: True | GrantTrigger: True
has SwallowedClause: True | Replacement_Instead: True
duration values: Counter({'UntilEndOfTurn': 2})

No AddTargetReplacement, no UntilHostLeavesPlay. It is a Continuous static carrying the quoted grant, exactly as you described. The card I named as the exemplar of the harmed class is not in the affected population at all.

2. The affected population contains zero temporary durations — confirmed, and it reproduces your split exactly. I filtered card-data.json for the stamped shape (event: Moved + valid_card: SelfRef + executeChangeZone{origin: Battlefield, destination: Exile, target: SelfRef}), restricted to riders whose baseline expiry is (the only ones this PR changes), and grouped by nearest enclosing duration:

baseline expiry==NULL rider cards (the ∅ population the PR stamps): 64

  57  ('Permanent', 'nocond')
   7  ('None',      'nocond')

--- any ∅-rider with a TEMPORARY enclosing duration? ---
count: 0

64, matching the parse-diff's 64 card(s), 1 signature(s) precisely — 57 Permanent (the Unearth keyword cards) plus 7 no-duration (the standalone reanimation riders). Since expiry_from_duration maps both Permanent and absent to None, a conditional "host-lifetime only when no enclosing duration applies" design would derive host-lifetime for all 64, behaviourally identical to the flat stamp. There is no card left to exercise the branch I asked for. Adding it would be a guard for an unreachable state, and you were right to push back on that per the no-speculative-guards guidance.

3. The 64-card expiry delta is fully explained — this is what I misread. There are 7 cards carrying a Moved→Exile SelfRef rider under an enclosing UntilEndOfTurn (Bleed Dry, Burn from Within, Carbonize, Disintegrate, Necrotic Wound, Ob Nixilis's Cruelty, Smite the Deathless). Every one of them already carries expiry: EndOfTurn in the baseline from the die-exile stamp:

--- already-stamped (die-exile) riders, untouched by PR ---
  46  ("{'type': 'EndOfTurn'}", 'None')
   7  ("{'type': 'EndOfTurn'}", 'UntilEndOfTurn')

They are not in the set, so this PR does not touch them. I had read the 64-card delta as evidence that temporary-duration riders were being retagged; the delta is entirely Unearth plus reanimation riders.

4. Single-caller claim — confirmed. try_parse_leave_battlefield_exile_replacement is private at crates/engine/src/parser/oracle_effect/mod.rs:1768 with exactly one production caller at :7642 (your :7650 was line drift, immaterial) and no other reference workspace-wide.

5. Seam and blast radius. The change sits on existing authorities and reuses their vocabulary: the is_runtime_*_replacement predicate family in printed_cards.rs, the durable_die_exile base-install precedent at add_target_replacement.rs:283, and the existing host-left arm of prune_controller_controls_source_on_leave at layers.rs:724. No new parallel machinery.

6. The variant is genuinely inert to turn machinery — verified, not just asserted. Your types/ability.rs doc claims UntilHostLeavesPlay is "NEVER pruned by turn machinery". I checked every prune site rather than trusting the comment, because a pre-existing wildcard arm would silently absorb a new variant and green CI would never show it. All of them use positive matches! on specific variants — turns.rs:1869 (expires_at_eot), :1957, :211/:215, :1221 — so nothing sweeps the new variant in. The claim holds.

7. Serialized surface is intact. client/src/adapter/types.ts:849 mirrors RestrictionExpiry as a hand-maintained TS union that lacks UntilHostLeavesPlay, which I checked as a possible drift point. It is used only by GameRestriction (:864, :873, :881), and ReplacementDefinition is not exposed to TypeScript at all. Since the new variant is only ever set on ReplacementDefinition.expiry, the union stays complete. No client change needed.

8. CR citations. I grep-verified all eight against docs/MagicCompRules.txt — 702.84a, 614.1a, 400.7, 613.1, 707.2, 608.2c, 611.2b, 701.21a. All resolve and all describe the annotated code.

9. Runtime tests are discriminating. The integration rows drive the real pipeline (activateresolve → a genuine evaluate_layers pass → real sacrifice/destroy/bounce), and R1 carries a PermanentSacrificed reach guard so the exile assertion cannot pass because the cost silently failed.

🟡 Non-blocking

[LOW] Under-scoped CR citation on one test comment. crates/engine/src/parser/oracle_effect/tests.rs:39299-39301:

// CR 400.7 (issue #5976): the parsed rider is stamped with the
// host-lifetime expiry so it is base-installed, non-copiable, and
// pruned on host exit.

CR 400.7 covers only the last clause (no relation to previous existence → no revival on same-ObjectId re-entry). Base-install-survives-reseed is CR 613.1 and non-copiable is CR 707.2. Every other comment you added carries the full triad; this one line is the outlier. Suggested fix: // CR 613.1 + CR 707.2 + CR 400.7 (issue #5976): ….

For the record, CodeRabbit flagged this same line but its proposed correction is wrong — it asks you to replace CR 400.7 with CR 702.84a and treat the prune behaviour as "engine semantics, not rule citation". CR 400.7 is precisely the right rule for the no-revival claim, as its own web-search output confirms. Widen the citation rather than swapping it.

[LOW] Mixed abstraction on a shared enum. crates/engine/src/types/ability.rs:2605 — your own doc notes UntilHostLeavesPlay "is meaningless for the state-hosted GameRestriction / pending_damage_replacements that share this enum". That is an object-lifetime anchor living on an enum whose other four variants are turn/phase-structure anchors. The die-exile stamp set this precedent so I am not asking you to change it here, and documenting the split explicitly is the right call. Noting it so the constraint is discoverable if this enum is ever split.

Follow-up

Your read on Expressionist being broken in the opposite direction checks out at the seam you cited: layers.rs:6590-6598 handles ContinuousModification::GrantAbility by pushing the concretized body into obj.abilities, never into replacement_definitions. A granted replacement-shaped ability therefore sits inert, and its entry still carries a SwallowedClause/Replacement_Instead warning. Please do file that as its own issue and take it — a granted, duration-bound replacement ability in the quoted-grant classifier is the correct fix and is properly out of scope here.

Recommendation: land as-is. The blocker is withdrawn — I am no longer holding this PR, and the outstanding changes-requested state should be cleared on that basis. The two LOW items are optional and need not gate it; fold the CR-citation widening in only if you are pushing for another reason. Apologies for the round-trip — the trace you supplied was specific enough to falsify my claim in one query, which is exactly what made it cheap to check.

@matthewevans matthewevans self-assigned this Jul 23, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving — the earlier changes-request on this head is withdrawn and this supersedes it.

The blocker claimed that a generic leave-battlefield exile rider would unconditionally override an enclosing until-end-of-turn duration. Re-derived from card-data.json, that claim is false on every leg:

  • Elemental Expressionist is not in the affected population. It carries no AddTargetReplacement and no UntilHostLeavesPlay, so it never reaches the stamped path. The exemplar the blocker was built on does not exist in the class.
  • The 64-card ∅-rider population contains zero temporary enclosing durations (57 Permanent + 7 None). Since expiry_from_duration maps both to None, the conditional design I asked for would derive host-lifetime for all 64 — behaviourally identical to the flat stamp, i.e. a guard for an unreachable state.
  • The 7 UntilEndOfTurn rider cards already carry expiry: EndOfTurn in the baseline from the die-exile stamp, so they are outside the ∅ set and untouched by this PR.

Seam, blast radius, prune-site inertness, TS surface, CR citations, and runtime-test discrimination were all independently confirmed. Thank you for the trace — it was specific enough to falsify a maintainer blocker in a single query, which is exactly what made it cheap to check. Apologies for the round-trip.

Two non-blocking LOW items, deliberately not gating this merge and not worth a push on their own:

  1. The CR citation on the tests.rs comment would be better widened to // CR 613.1 + CR 707.2 + CR 400.7 (issue #5976). For the record, CodeRabbit's proposal on that same line is wrong — it wants CR 400.7 replaced with CR 702.84a; CR 400.7 is precisely the right rule for the no-revival claim. Widen, do not swap.
  2. The documented mixed-abstraction note on types/ability.rs:2605 — precedent was set by the die-exile stamp; no change requested.

The Expressionist issue you identified in the opposite direction (granted replacement-shaped abilities landing in obj.abilities rather than replacement_definitions) is real and correctly out of scope here — please file and take it.

Landing as-is.

@matthewevans matthewevans added the bug Bug fix label Jul 23, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 23, 2026
@matthewevans matthewevans removed their assignment Jul 23, 2026
@michiot05

Copy link
Copy Markdown
Contributor Author

Appreciate you re-deriving it from the corpus rather than taking my numbers on trust — and no need to apologize, a specific blocker is what made it cheap to falsify in the first place. Landing as-is per your note; I'll leave the two LOW items alone unless the PR needs another push for some other reason, in which case I'll widen that tests.rs citation to CR 613.1 + CR 707.2 + CR 400.7 (agreed on widen-not-swap; 400.7 is the right rule for the no-revival clause).

And yes — I'll file the granted-replacement gap as its own issue and take it: quoted it gains "if it would leave the battlefield, exile it instead…" grants currently land in obj.abilities via ContinuousModification::GrantAbility (layers.rs:6590-6598) and never become a replacement, so the redirect sits inert (with the SwallowedClause/Replacement_Instead warning) across the 7 quoted-grant cards. The fix is a granted, duration-bound replacement ability in the quoted-grant classifier, honoring the grant's UntilEndOfTurn via the existing transient path — cleanly separate from this PR's host-lifetime stamp on standalone riders.

Model: claude-opus-4-8

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 23, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 23, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Jul 24, 2026
@matthewevans matthewevans self-assigned this Jul 24, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
@matthewevans matthewevans removed their assignment Jul 24, 2026
Merged via the queue into phase-rs:main with commit c46f3f1 Jul 24, 2026
15 checks passed
michiot05 added a commit to michiot05/phase that referenced this pull request Jul 24, 2026
…ust actually fire

A quoted grant of a replacement-shaped ability — Geth, Thane of
Contracts' 'It gains "If this creature would leave the battlefield,
exile it instead of putting it anywhere else"' — parsed to an inert
ContinuousModification::GrantAbility. The concretized body was pushed
into obj.abilities and never into replacement_definitions, so the
redirect silently did nothing and the reanimated creature went to the
graveyard on any battlefield exit.

Adds ContinuousModification::GrantReplacement, the replacement-store
sibling of GrantAbility/GrantTrigger/GrantStaticAbility (CR 614 is a
categorically distinct sink from CR 602/603/604). It is applied at
layer 6 with structural dedup and re-derived every layer pass
(CR 613.1f), so its lifetime is governed by the granting continuous
effect rather than an object-lifetime stamp: a permanent grant survives
its source leaving, and a temporary one (Elemental Expressionist's
"Until end of turn") lapses at cleanup (CR 611.2a, CR 514.2). A grant
carrying other modifications alongside the replacement is never
promoted to permanent — Expressionist grants two abilities, so that
guard is load-bearing and is pinned by its own regression.

Also fixes the parser gap that made the rider unreachable at this seam:
normalize_card_name_refs rewrites "this creature|permanent|land" to
"~" card-wide (CR 201.5b), but parse_leave_battlefield_rider_ref had
no "~" arm, so every quoted grant failed to parse. The standalone
rider path (phase-rs#6538) is unaffected — no standalone printing uses a
"this <type>" subject. The Moved→Exile ReplacementDefinition is now
built by one shared constructor with three consumers: this granted
path, the standalone parser rider, and unearth's synthesis
(CR 702.84a), whose emitted definition is byte-identical to before.

Closes phase-rs#6566

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
matthewevans added a commit to andriypolanski/phase that referenced this pull request Jul 24, 2026
Resolves the deterministic crates/engine/tests/integration/main.rs mod-line
conflict by keeping both test modules in rustfmt-sorted order. Maintainer-side
port: main advanced under this PR tonight (phase-rs#6538 phase-rs#6543 phase-rs#6545 phase-rs#6554 phase-rs#6563 phase-rs#6579).
The phase-tauri 0.35.1->0.35.2 client/src-tauri/Cargo.lock bump is now a no-op,
since main landed the identical bump in phase-rs#6568.
matthewevans added a commit to michiot05/phase that referenced this pull request Jul 24, 2026
Ports this PR onto phase-rs#6538 (same author, same subsystem), which landed tonight.

Resolves two conflicts in crates/engine/src/database/unearth.rs, both to the PR
side, which is this PR's contribution (deduplicate the inline
`ReplacementDefinition` construction behind the single
`leave_battlefield_exile_replacement()` authority):
  - import list: `ReplacementDefinition`/`RestrictionExpiry` are no longer
    referenced in this file once the construction is delegated (remaining
    mentions are doc comments only), so they are dropped.
  - `leaves_battlefield_exile_step()`: keep the delegation to the shared helper.

Verified behavior-preserving: the shared `leave_battlefield_exile_replacement_impl()`
is field-for-field identical to the inline form main built
(`ReplacementEvent::Moved` + `valid_card: SelfRef` + `expiry:
UntilHostLeavesPlay` + `ChangeZone` Battlefield->Exile on `SelfRef`). Note the
auto-merge of parser/oracle_effect/mod.rs correctly grafted main's newly added
`.expiry(RestrictionExpiry::UntilHostLeavesPlay)` stamp into the extracted
helper, which the PR head lacked -- main's fix is preserved, not reverted.
michiot05 added a commit to michiot05/phase that referenced this pull request Jul 24, 2026
…ust actually fire

A quoted grant of a replacement-shaped ability — Geth, Thane of
Contracts' 'It gains "If this creature would leave the battlefield,
exile it instead of putting it anywhere else"' — parsed to an inert
ContinuousModification::GrantAbility. The concretized body was pushed
into obj.abilities and never into replacement_definitions, so the
redirect silently did nothing and the reanimated creature went to the
graveyard on any battlefield exit.

Adds ContinuousModification::GrantReplacement, the replacement-store
sibling of GrantAbility/GrantTrigger/GrantStaticAbility (CR 614 is a
categorically distinct sink from CR 602/603/604). It is applied at
layer 6 with structural dedup and re-derived every layer pass
(CR 613.1f), so its lifetime is governed by the granting continuous
effect rather than an object-lifetime stamp: a permanent grant survives
its source leaving, and a temporary one (Elemental Expressionist's
"Until end of turn") lapses at cleanup (CR 611.2a, CR 514.2). A grant
carrying other modifications alongside the replacement is never
promoted to permanent — Expressionist grants two abilities, so that
guard is load-bearing and is pinned by its own regression.

Also fixes the parser gap that made the rider unreachable at this seam:
normalize_card_name_refs rewrites "this creature|permanent|land" to
"~" card-wide (CR 201.5b), but parse_leave_battlefield_rider_ref had
no "~" arm, so every quoted grant failed to parse. The standalone
rider path (phase-rs#6538) is unaffected — no standalone printing uses a
"this <type>" subject.

The Moved→Exile ReplacementDefinition is built by one shared,
deliberately UNSTAMPED constructor (leave_battlefield_exile_replacement,
expiry: None) with three consumers that decide the lifetime themselves:

  * the standalone parser rider (try_parse_leave_battlefield_exile_
    replacement) and unearth's synthesis (CR 702.84a) each compose
    .expiry(UntilHostLeavesPlay) — reproducing phase-rs#6538's host-lifetime
    stamp byte-for-byte (CR 400.7), so phase-rs#6538's base-install /
    non-copiable / host-exit-prune machinery is preserved exactly; and

  * the granted path (classify_quoted_inner → GrantReplacement) uses the
    parser fn purely as a DETECTOR and grants the UNSTAMPED constructor
    output. A granted replacement's lifetime is governed by the grant's
    duration and re-derived every layer pass (CR 611.2a / CR 613.1f); a
    host-lifetime stamp would be read by phase-rs#6538's
    is_runtime_host_lifetime_replacement (base-install + non-copiable +
    host-exit-prune), base-installing the granted rider so it would
    outlive the granting continuous effect and Expressionist's
    until-end-of-turn grant would never lapse. The reconciliation is
    pinned by granted_replacement_is_not_host_lifetime_stamped (RED if
    the granted def is ever re-stamped) and the byte-identity test
    leave_battlefield_exile_replacement_matches_6538_shape (RED if the
    standalone shape drifts from phase-rs#6538).

Closes phase-rs#6566

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
andriypolanski pushed a commit to andriypolanski/phase that referenced this pull request Jul 24, 2026
…ust actually fire (phase-rs#6580)

A quoted grant of a replacement-shaped ability — Geth, Thane of
Contracts' 'It gains "If this creature would leave the battlefield,
exile it instead of putting it anywhere else"' — parsed to an inert
ContinuousModification::GrantAbility. The concretized body was pushed
into obj.abilities and never into replacement_definitions, so the
redirect silently did nothing and the reanimated creature went to the
graveyard on any battlefield exit.

Adds ContinuousModification::GrantReplacement, the replacement-store
sibling of GrantAbility/GrantTrigger/GrantStaticAbility (CR 614 is a
categorically distinct sink from CR 602/603/604). It is applied at
layer 6 with structural dedup and re-derived every layer pass
(CR 613.1f), so its lifetime is governed by the granting continuous
effect rather than an object-lifetime stamp: a permanent grant survives
its source leaving, and a temporary one (Elemental Expressionist's
"Until end of turn") lapses at cleanup (CR 611.2a, CR 514.2). A grant
carrying other modifications alongside the replacement is never
promoted to permanent — Expressionist grants two abilities, so that
guard is load-bearing and is pinned by its own regression.

Also fixes the parser gap that made the rider unreachable at this seam:
normalize_card_name_refs rewrites "this creature|permanent|land" to
"~" card-wide (CR 201.5b), but parse_leave_battlefield_rider_ref had
no "~" arm, so every quoted grant failed to parse. The standalone
rider path (phase-rs#6538) is unaffected — no standalone printing uses a
"this <type>" subject.

The Moved→Exile ReplacementDefinition is built by one shared,
deliberately UNSTAMPED constructor (leave_battlefield_exile_replacement,
expiry: None) with three consumers that decide the lifetime themselves:

  * the standalone parser rider (try_parse_leave_battlefield_exile_
    replacement) and unearth's synthesis (CR 702.84a) each compose
    .expiry(UntilHostLeavesPlay) — reproducing phase-rs#6538's host-lifetime
    stamp byte-for-byte (CR 400.7), so phase-rs#6538's base-install /
    non-copiable / host-exit-prune machinery is preserved exactly; and

  * the granted path (classify_quoted_inner → GrantReplacement) uses the
    parser fn purely as a DETECTOR and grants the UNSTAMPED constructor
    output. A granted replacement's lifetime is governed by the grant's
    duration and re-derived every layer pass (CR 611.2a / CR 613.1f); a
    host-lifetime stamp would be read by phase-rs#6538's
    is_runtime_host_lifetime_replacement (base-install + non-copiable +
    host-exit-prune), base-installing the granted rider so it would
    outlive the granting continuous effect and Expressionist's
    until-end-of-turn grant would never lapse. The reconciliation is
    pinned by granted_replacement_is_not_host_lifetime_stamped (RED if
    the granted def is ever re-stamped) and the byte-identity test
    leave_battlefield_exile_replacement_matches_6538_shape (RED if the
    standalone shape drifts from phase-rs#6538).

Closes phase-rs#6566

Co-authored-by: michiot05 <281539540+michiot05@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jsdevninja pushed a commit to jsdevninja/phase that referenced this pull request Jul 24, 2026
…ve layer resets (Rotting Rats + Sacrifice) (phase-rs#6538)

Unearth installs a rider — "if it would leave the battlefield, exile it
instead of putting it anywhere else" (CR 702.84a) — on the returned
creature, but it was pushed to the object's LIVE replacement set only.
Every layer pass reseeds live characteristics from base (CR 613.1), so
the rider was wiped before any real leave event; sacrificing, destroying,
or bouncing an unearthed creature sent it to the graveyard instead of
exile. Only the end-step delayed-trigger half worked (it is state-hosted,
not object-hosted).

Adds a typed RestrictionExpiry::UntilHostLeavesPlay stamped on the rider
at both build sites (unearth synthesis + the Whip-of-Erebos-class parser
builder). The stamp (1) forces base-install so the rider survives CR
613.1 reseeds, (2) marks the def non-copiable (CR 707.2 — token copies
never inherit it), and (3) prunes it base+live when the host leaves the
battlefield (CR 400.7 — a re-entered same-ObjectId object is a new object
and must not be re-exiled). Object-bound, so it survives control theft.
Fixes ~58 unearth cards + 6 leave-battlefield-rider cards
(Whip of Erebos, Gruesome Encore, Isareth, Kheru Lich Lord, Moira and
Teshar, From the Catacombs). Personal Decoy's printed static (no expiry
stamp) stays copiable — the detectors key on the stamp, not the shape.

Closes phase-rs#5976

Co-authored-by: michiot05 <281539540+michiot05@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unearth bug — If I return [[Rotting Rats]] to the battlefield using its unearth ability and then sacrifice it to [[Sacr…

2 participants