Skip to content

Fix Jailbreak — bind the CR 110.2a "under their control" clause (#6691) - #6814

Open
keloide wants to merge 4 commits into
phase-rs:mainfrom
keloide:claude/phase-developer-track-card-8qz8o1
Open

Fix Jailbreak — bind the CR 110.2a "under their control" clause (#6691)#6814
keloide wants to merge 4 commits into
phase-rs:mainfrom
keloide:claude/phase-developer-track-card-8qz8o1

Conversation

@keloide

@keloide keloide commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Addresses #6691 for the FilterProp::Owned template and the player_scope fan-out template; does not close it — residue enumerated below.

The parser recognised only "under your control" and the owner-stating forms. "under their control" / "under that player's control" fell through the empty-tag arm of the alternation at oracle_effect/lower.rs:6705 and were silently dropped, so permanents entered under the wrong controller while the cards reported supported: true, gap_count: 0 — a silent wrong-game-result class.

This collapses four independently-implemented copies of the under <possessor> control grammar into one nom combinator module and binds the anaphor from typed parse products only, never a text scan or a lowered-tree walk:

  • N1 — the moved object's own FilterProp::Owned { controller != You }ControllerRef::ParentTargetOwner. Licensed by CR 400.1 + 400.3 + 404.1 (a card in a graveyard is in its owner's graveyard) + CR 108.3.
  • N2ctx.relative_player_scope, mapped through an exhaustive, wildcard-free table admitting only ScopedPlayer, ChosenPlayer, TriggeringPlayer. TargetPlayer/TargetOpponent are refused by name: two incompatible provenances, and TargetPlayer resolves to the first player target, which would put every land under one player's control on a multi-target cast (Turtle Tracks).

Anything unbindable fails closed to Effect::unimplemented, so a clause the parser cannot bind becomes an honest coverage gap instead of a wrong controller. EntersUnderSpec carries that third state — which Option<ControllerRef> cannot express, and whose absence is the bug — through the AST to every lowering site.

Files changed

  • crates/engine/src/parser/oracle_nom/enters_under.rs (new)
  • crates/engine/src/parser/oracle_nom/mod.rs
  • crates/engine/src/parser/oracle_ir/ast.rs
  • crates/engine/src/parser/oracle_effect/{lower,mod,imperative,sequence,tests}.rs
  • crates/engine/src/parser/oracle_ir/snapshots/*.snap (2)
  • crates/engine/tests/integration/issue_6691_enters_under_their_control.rs (new), tests/integration/main.rs, tests/fixtures/integration_cards.json

CR references

CR 110.2a @618 (authorizing) · 110.2 @616 · 110.1 @614 · 608.2c @2793 · 608.2k @2814 · 608.2e @2798 · 400.1 @1933 · 400.3 @1937 · 404.1 @2030 · 108.3 @564 · 109.4 @594 · 109.5 @610 · 115.10 @886 · 102.2/102.3 @252/254 · 603.2 @2561 · 614.1c @3060 · 508.4 @2309 · 708.3 @5707

All grep-verified in docs/MagicCompRules.txt before being written; the diff gate returns zero UNVERIFIED.

Measured impact (full-pool diff, not predicted)

Regenerated card-data.json before/after. Exactly 7 cards move; zero others change:

Card before after
Jailbreak (dropped) ParentTargetOwner
Gerrymandering (dropped) ScopedPlayer
Thieves' Auction (dropped) ScopedPlayer
The Beamtown Bullies (silently wrong) honest gap
Endless Whispers (silently wrong) honest gap
Plague Reaver (silently wrong) honest gap
Turtle Tracks (silently wrong) honest gap

933 your/owner-form cards (558+316+27+20+6+6) keep byte-identical behaviour while flowing through a single grammar. The 13 deferred possessor forms become a one-alt-arm extension instead of four parallel edits.

Coverage moves down by 4 by design: those cards were reporting gap_count: 0 on a priority:p2-wrong-game-result clause. An honest gap beats a wrong controller.

Implementation method

Method: /engine-implementer — plan → /review-engine-plan x2 (both returned blockers; both resolved) → implement → /review-impl (returned a blocker; resolved, see Validation Failures).

Track

Developer

LLM

Model: claude-opus-5
Thinking: high

Verification

  • Required checks ran clean.

  • Gate A output below is for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • Final review-impl could not be re-run after the last commit — see Validation Failures.

  • cargo fmt --all — clean

  • cargo check -p engine --lib — 0 errors, 0 warnings

  • cargo test -p engine --lib parser::8678 passed; 0 failed

  • cargo test -p engine --test integration4164 passed; 0 failed

  • cargo clippy -p engine --all-targets — no warnings

  • ./scripts/gen-card-data.sh — regenerated; full-pool diff above

  • Manual grep for the ungated blind spots (.rfind/.split/.split_once/.contains() in added parser lines — one hit, a test assertion on a debug dump at tests.rs:29926, not dispatch. No allow-noncombinator added anywhere.

Revert-sensitivity proven, not asserted. Temporarily binding EntersUnderSpec::Default instead of Override(ParentTargetOwner) makes jailbreak_enters_under_their_control_binds_to_the_owner FAIL; restoring makes it pass.

Gate A

Gate A PASS head=2b252930196ab8425c4293a4c7394303575ccb75 base=c754a087a28ce6cabb46ea5c8fa6bfa095e79008

Anchored on

  • crates/engine/src/parser/oracle_effect/lower.rs:6705 — the existing enters_under alt() binding " under your control" -> Some(ControllerRef::You) and returning None for the owner forms. The exact seam the new grammar replaces; it already carried its own CR 110.2a annotation.
  • crates/engine/src/parser/oracle_effect/lower.rs:6641 — the sibling destination-table carrier enters_under: enters_under_you.then_some(ControllerRef::You), showing how the same controller override is threaded through the phrase-table path.

Claimed parse impact

  • Jailbreak
  • Gerrymandering
  • Thieves' Auction
  • The Beamtown Bullies (now an honest gap)
  • Endless Whispers (now an honest gap)
  • Plague Reaver (now an honest gap)
  • Turtle Tracks (now an honest gap)

Residue — why this does not close #6691

  1. Rootweaver Druid stays supported: true, gap_count: 0 with enters_under: "You" on its "and the rest onto the battlefield tapped under their control" leg — parser: under their control binding unparsed — permanents enter under the wrong controller (CR 110.2a) #6691's exact failure mode surviving this fix, because that leg is produced by a dig/reveal path this change does not rewire (sequence.rs:5468/5560/6868/6930).
  2. Immortal Obligation is intercepted upstream by is_static_pattern() at priority 7 (its text contains "can't block") and never reaches these seams — a separate line-classifier defect.
  3. Four cards move to an honest gap rather than a fix (table above).
  4. Un-rewired detection sites remain, named in-source with // FOLLOW-UP comments: the four dig/reveal legs, imperative.rs:390-398, imperative.rs:6440, and oracle_trigger.rs:9597 (the trigger-condition layer, CR 603.2 — deliberately out of scope, and the home of the 7 Trap/condition cards that must not be touched).
  5. 13 deferred possessor forms (under an opponent's control etc.) are explicit NPs, not anaphors, and are unchanged.

Validation Failures

The final review-impl pass could not be re-run against the current head 2b25293. An independent review-impl did run against the preceding head and returned one BLOCKER plus five lower findings; the BLOCKER is fixed in this branch (commit 2 — the integration test passed with the fix reverted, because the fixture entry was stale and the runtime default coincides with the correct answer for Jailbreak). The environment then exhausted its model budget, so the confirming re-review did not run.

Recorded but not fixed, for maintainer triage:

  • (MED) Seam D has no dedicated production-path test. No printed card reaches it with a third-person clause, so no non-synthetic fixture exists.
  • (MED) Fail-closed is not total on the chain-patcher path. apply_search_destination_to_ability_chain is called with None on an unbound anaphor so the destination/tapped patches survive (deliberately — skipping the call would drop them), but a predecessor ChangeZone can still execute with the default controller while only the newly-pushed def becomes Unimplemented.
  • (MED) fold_control_clauses widens the pattern set over an unchanged span. The span is byte-identical to the literal it replaces, but the fold now also matches the anaphor anywhere in it. No printed card currently pairs a conditional "...enter the battlefield under their control this turn" with a battlefield put on the same normalized line, so the guard is incidental rather than structural.
  • (LOW) One annotation cites CR 110.2 where the licensing authority is CR 110.2a plus the engine convention that enters_under: None means "keep current controller".
  • (LOW) Seam-A offset arithmetic maps lower->text byte offsets across a clause containing ' and U+2019. Both are length-preserving under to_lowercase (so the flagged risk is not live), but TextPair would make it structural.

Also not run: cargo coverage, cargo semantic-audit, ordering_parity_sweep, scripts/coverage-regression-check.sh. The full-pool card-data.json diff above is the substantive evidence in their place; predicted bucket parser_regress (non-fatal), net supported delta -4, which CI will confirm.

CI Failures

None observed locally.


Generated by Claude Code

Summary by CodeRabbit

  • New Features
    • Improved handling of “enters under [possessor]’s control” clauses for entering/returning to the battlefield.
    • Correctly supports owner/third-person control wording and related anaphor forms.
  • Bug Fixes
    • Fail-closed behavior for unbindable control anaphors, preventing incorrect fallback to the resolving player.
    • Strengthened “enters under” binding through zone-change construction, including cases where control details are partially unavailable.
  • Tests
    • Added regression and integration coverage for CR 110.2a (including Jailbreak returning under the victim’s owner’s control) and updated return-destination assertions.

claude added 2 commits July 30, 2026 08:51
…s#6691)

The Oracle parser recognised only "under your control" and the
owner-stating forms. "under their control" / "under that player's
control" fell through the empty-tag arm and were silently dropped, so
permanents entered under the wrong controller while the cards reported
supported: true, gap_count: 0 — a silent wrong-game-result class.

Collapse four independently-implemented copies of the
`under <possessor> control` grammar into one nom combinator module
(oracle_nom/enters_under.rs) and bind the anaphor from typed parse
products only:

  N1 — the moved object's own FilterProp::Owned { controller != You }
       (CR 400.1 + 400.3 + 404.1 + 108.3: a card in a graveyard is in
       its owner's graveyard) -> ControllerRef::ParentTargetOwner
  N2 — ctx.relative_player_scope, mapped through an exhaustive
       wildcard-free table that admits only ScopedPlayer, ChosenPlayer
       and TriggeringPlayer

Everything else fails closed to Effect::unimplemented rather than
silently defaulting, so a clause the parser cannot bind becomes an
honest coverage gap instead of a wrong controller. EntersUnderSpec
carries that third state through the AST to every lowering site.

Fixes Jailbreak (ParentTargetOwner) and Gerrymandering (ScopedPlayer)
and their templates; 933 "your"/owner-form cards keep byte-identical
behaviour through the single grammar.

Addresses phase-rs#6691; does not close it — see the PR body for the residue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Nr9Cg6eBZfBod5Twb6Vyi
…h + scope correction

Review found the integration test passed with the fix reverted. Two causes,
both fixed here:

1. The surgically-added `jailbreak` fixture entry was copied from a PRE-FIX
   `card-data.json`, so it carried no `enters_under` key. Since the field is
   `skip_serializing_if = "Option::is_none"`, it deserialized as the unfixed
   AST and the test measured old behaviour while appearing to pass. The entry
   is regenerated from post-fix card data (+0 keys, only `jailbreak` changed)
   and a fixture-integrity guard now fails loudly if it ever goes stale again.

2. The test claimed to be revert-failing. It is not, and that was MEASURED, not
   assumed: with `enters_under` stripped it still passes, because a graveyard →
   battlefield move already resolves the new object's controller to its OWNER,
   which for Jailbreak is exactly the player the clause names. Staging a
   divergent controller does not separate them either — the move reassigns it.
   The docstring now states this plainly and points at the discriminating test.

The discriminating test is the parser-level assertion
`jailbreak_enters_under_their_control_binds_to_the_owner`, verified by
temporarily binding `Default` instead of `ParentTargetOwner`: it FAILS on
revert and passes when restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Nr9Cg6eBZfBod5Twb6Vyi
@keloide
keloide requested a review from matthewevans as a code owner July 30, 2026 14:11
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The parser adds control-clause parsing and binding with explicit, default, and unbound states. Zone-change effects propagate these states, lowering unbound anaphors to unimplemented effects. Prevent-damage recipient resolution and Jailbreak regression coverage are also added.

Changes

Enters-under control binding

Layer / File(s) Summary
Control clause contract and binding
crates/engine/src/parser/oracle_nom/enters_under.rs, crates/engine/src/parser/oracle_ir/ast.rs
Adds control-clause parsing, antecedent resolution, binding helpers, and the three-state EntersUnderSpec representation with unit tests.
Return destination control parsing
crates/engine/src/parser/oracle_effect/lower.rs, crates/engine/src/parser/oracle_effect/tests.rs
Stores parsed control possessors on return destinations and consumes leading battlefield clauses while preserving riders.
Zone-change binding and fail-closed lowering
crates/engine/src/parser/oracle_effect/imperative.rs, crates/engine/src/parser/oracle_effect/mod.rs
Propagates bound control specifications through zone changes and emits unimplemented effects for unbound anaphors.
Search continuation propagation
crates/engine/src/parser/oracle_effect/sequence.rs, crates/engine/src/parser/oracle_effect/mod.rs
Binds control clauses in search continuations using parse context and applies the resulting controller reference or fail-closed effect.
Parser and runtime regression coverage
crates/engine/src/parser/oracle_effect/tests.rs, crates/engine/tests/integration/*
Verifies explicit, owner, default, and unbound control outcomes, including a three-player Jailbreak scenario.

Prevent-damage recipient resolution

Layer / File(s) Summary
Recipient resolver and regression coverage
crates/engine/src/parser/oracle_effect/imperative.rs
Resolves prevent-damage recipients by priority and retains TargetFilter::Any when no recipient phrase is recognized.

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

Sequence Diagram(s)

sequenceDiagram
  participant CardText
  participant ReturnDestinationParser
  participant ControlClauseBinder
  participant ZoneChangeLowering
  participant GameEffect
  CardText->>ReturnDestinationParser: parse battlefield destination and control clause
  ReturnDestinationParser->>ControlClauseBinder: pass possessor and antecedent context
  ControlClauseBinder->>ZoneChangeLowering: return EntersUnderSpec
  ZoneChangeLowering->>GameEffect: create controller override or unimplemented anaphor effect
Loading

Possibly related PRs

Suggested labels: bug

Suggested reviewers: matthewevans, andriypolanski

🚥 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 is concise and accurately names the main fix and issue, matching the PR's core change.
Linked Issues check ✅ Passed The changes cover both the imperative and player-subject binding paths, so the CR 110.2a controller bug is addressed.
Out of Scope Changes check ✅ Passed The added parser module, AST plumbing, and tests all support the linked controller-binding fix.
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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
crates/engine/tests/fixtures/integration_cards.json

Checkov skipped this file: it is too large to scan (10844856 bytes)

🔧 ast-grep (0.45.0)
crates/engine/tests/fixtures/integration_cards.json

ast-grep skipped this file: it is too large to scan (10844856 bytes)

crates/engine/src/parser/oracle_effect/mod.rs

ast-grep timed out on this file


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

@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: 2

🧹 Nitpick comments (5)
crates/engine/src/parser/oracle_nom/enters_under.rs (1)

325-354: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The final (p, _) arm defeats the exhaustiveness discipline this module relies on elsewhere.

map_relative_player_scope is deliberately wildcard-free so "adding a ControllerRef variant breaks the build here" (Line 246). bind_control_clause gives up that property: the deferred possessor forms listed at Lines 150-160 ("under an opponent's control", "under target player's control", …) will, the moment they are added as ControlClausePossessor variants, fall silently into UnboundAnaphor rather than forcing a decision at this binding table. Fail-closed is the safe direction, but the card becomes an unexplained coverage gap instead of a compile error.

Match the possessor axis explicitly so the compiler flags a new variant.

As per coding guidelines: "Use exhaustive match expressions without wildcard fallbacks when matching known enums, allowing the compiler to detect missing variants."

♻️ Explicit anaphor arms instead of the tuple wildcard
-        // Fail closed: no nameable antecedent (and, for the demonstrative, an
-        // object-owner antecedent it may not legally use).
-        (p, _) => EntersUnderSpec::UnboundAnaphor(p),
+        // Fail closed: no nameable antecedent (and, for the demonstrative, an
+        // object-owner antecedent it may not legally use). Enumerated so a new
+        // possessor variant breaks the build here rather than becoming a silent
+        // coverage gap.
+        (
+            p @ (ControlClausePossessor::TheirAnaphor
+            | ControlClausePossessor::ThatPlayerDemonstrative),
+            ControlAnaphorAntecedent::MovedObjectOwner | ControlAnaphorAntecedent::Unnameable,
+        ) => EntersUnderSpec::UnboundAnaphor(p),
🤖 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_nom/enters_under.rs` around lines 325 - 354,
Update bind_control_clause to remove the tuple wildcard `(p, _)` and enumerate
each currently unsupported ControlClausePossessor variant explicitly, returning
UnboundAnaphor for those cases. Preserve the existing You, Owner, TheirAnaphor,
and ThatPlayerDemonstrative behavior while making the match exhaustive so adding
a new possessor variant produces a compile-time error.

Source: Coding guidelines

crates/engine/src/parser/oracle_effect/sequence.rs (1)

5308-5326: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

The searched player's filter is available here and would license the CR 108.3 owner antecedent.

Passing None as moved_object is accurate for this AST shape, but the information is not actually absent: the preceding Effect::SearchLibrary in defs carries the player filter whose library is searched, and for "search target opponent's library … put it onto the battlefield under their control" that player is the found card's owner (CR 400.3 — a card in a library is in its owner's library). As written, such a card can only reach ContextPlayer or fail closed. Not a defect in this PR's scope, but worth a note here (or in the deferred list) so the seam's gap is attributable rather than looking like an inherent limitation.

🤖 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/sequence.rs` around lines 5308 - 5326,
Document this deferred limitation at the bind_control_clause seam: although
moved_object is correctly None for this AST shape, the preceding
Effect::SearchLibrary in defs provides the searched player filter, which can
identify the found card’s owner for CR 108.3. Add a concise note here or to the
deferred-work list explaining that opponent-library searches currently cannot
use this owner antecedent and may resolve only to ContextPlayer or fail closed.
crates/engine/src/parser/oracle_ir/ast.rs (1)

220-271: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Three-state contract is only half-adopted across enters_under fields.

SearchDestination, ReturnToBattlefield, ReturnAllToZone, ZoneChange, and ZoneChangeAll now carry EntersUnderSpec, but the sibling controller-override fields in this same file stay Option<ControllerRef>:

  • ContinuationAst::DigFromAmong.enters_under (Line 406)
  • ContinuationAst::RevealUntilKept.enters_under (Line 469)
  • PutImperativeAst::Manifest.enters_under (Line 1511)

Those three are fed by literal scan_contains(lower, "under your control") checks in oracle_effect/sequence.rs, so a printed "under their control" / "under that player's control" at those seams still degrades to the CR 110.2 default rather than failing closed — exactly the class this PR is closing. Today no printed card appears to reach them with a third-person clause, so this is a coverage/uniformity gap rather than an active misparse; worth either migrating them onto EntersUnderSpec in a follow-up or recording the carve-out in this doc comment so the asymmetry is intentional and discoverable.

🤖 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_ir/ast.rs` around lines 220 - 271, Complete
the three-state contract for the remaining entry-controller fields: change
ContinuationAst::DigFromAmong.enters_under,
ContinuationAst::RevealUntilKept.enters_under, and
PutImperativeAst::Manifest.enters_under from Option<ControllerRef> to
EntersUnderSpec, and update their sequence-lowering/parsing paths to preserve
unbound third-person control clauses as UnboundAnaphor instead of defaulting.
Reuse EntersUnderSpec’s existing helpers and ensure all constructors and
consumers handle Default, Override, and UnboundAnaphor consistently.
crates/engine/src/parser/oracle_effect/tests.rs (1)

29864-29992: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a destination-layer unit test for the third-person control-clause fallback.

jailbreak_enters_under_their_control_binds_to_the_owner and unbindable_anaphor_fails_closed_instead_of_defaulting exercise the new TheirAnaphor/ThatPlayerDemonstrative fallback only through full parse_effect_chain/parse_oracle_text, conflating the destination-parsing layer (strip_return_destination_ext_with_remainder in lower.rs) with the downstream binding layer. A direct test mirroring return_destination_owners_control_not_under_your_control — e.g. asserting strip_return_destination_ext("it to the battlefield under their control").1.unwrap().control == Some(ControlClausePossessor::TheirAnaphor) — would isolate a regression in the new parse_leading_control_clause fallback from a regression in bind_control_clause.

🧪 Suggested additional test
#[test]
fn return_destination_under_their_control_recognized_as_anaphor() {
    let (_, dest) = strip_return_destination_ext("it to the battlefield under their control");
    let d = dest.expect("should parse destination");
    assert_eq!(d.control, Some(ControlClausePossessor::TheirAnaphor));
}
🤖 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 29864 - 29992,
Add a focused destination-layer unit test alongside return-destination tests,
using strip_return_destination_ext (or its remainder variant) to parse “it to
the battlefield under their control” and assert the destination control is
Some(ControlClausePossessor::TheirAnaphor). Keep this test independent of
parse_effect_chain and bind_control_clause so it specifically covers
parse_leading_control_clause’s fallback recognition.
crates/engine/src/parser/oracle_effect/imperative.rs (1)

2331-2336: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the fail-closed guard into one authority on EntersUnderSpec. The identical unbound_possessor()Effect::unimplemented("change_zone_enters_under_anaphor", p.printed_clause()) block, including the gap-name string literal, is repeated at five lowering sites. A single accessor (e.g. EntersUnderSpec::unimplemented_if_unbound(&self) -> Option<Effect>) keeps the gap name and CR 110.2a rationale in one place and makes a future lowering site that forgets the guard obvious.

  • crates/engine/src/parser/oracle_effect/imperative.rs#L2331-L2336: replace with the shared accessor (if let Some(e) = enters_under.unimplemented_if_unbound() { return e; }).
  • crates/engine/src/parser/oracle_effect/imperative.rs#L2396-L2401: same replacement in the ReturnAllToZone arm.
  • crates/engine/src/parser/oracle_effect/imperative.rs#L6762-L6767: same replacement in the PutImperativeAst::ZoneChangeAll arm.
  • crates/engine/src/parser/oracle_effect/imperative.rs#L6796-L6801: same replacement in the PutImperativeAst::ZoneChange arm.
  • crates/engine/src/parser/oracle_effect/imperative.rs#L11635-L11640: same, wrapping the returned effect in parsed_clause(..).
🤖 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/imperative.rs` around lines 2331 -
2336, Centralize the unbound-possessor fail-closed behavior in an accessor on
EntersUnderSpec, such as unimplemented_if_unbound, preserving the
change_zone_enters_under_anaphor gap name and printed clause. Replace the
duplicated guards at crates/engine/src/parser/oracle_effect/imperative.rs lines
2331-2336, 2396-2401, 6762-6767, and 6796-6801 with the accessor and return its
Effect; at lines 11635-11640, use the accessor and wrap the returned effect in
parsed_clause as currently required.
🤖 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/sequence.rs`:
- Around line 3727-3741: Handle the unbound enters_under.possessor() case as a
failed assembly: at
crates/engine/src/parser/oracle_effect/sequence.rs:3727-3741, stop or neutralize
the existing library-to-battlefield ChangeZone in previous before
apply_search_destination_to_ability_chain so no executable wrong-controller move
remains; at crates/engine/src/parser/oracle_effect/sequence.rs:3770-3781, skip
forward_result and Effect::Attach wiring when put_effect is the
Effect::unimplemented fragment, or fold the attach behavior into that fragment.

In `@crates/engine/tests/integration/issue_6691_enters_under_their_control.rs`:
- Around line 59-143: Add a separate integration test using a production card
ability with a named-player controller binding, such as The Beamtown Bullies’
“that player’s control,” where the default controller differs from the
referenced player. Build and resolve the scenario through the normal card
database and game runner pipeline, then assert the permanent enters under the
named player’s control and that the relevant effect does not revert.

---

Nitpick comments:
In `@crates/engine/src/parser/oracle_effect/imperative.rs`:
- Around line 2331-2336: Centralize the unbound-possessor fail-closed behavior
in an accessor on EntersUnderSpec, such as unimplemented_if_unbound, preserving
the change_zone_enters_under_anaphor gap name and printed clause. Replace the
duplicated guards at crates/engine/src/parser/oracle_effect/imperative.rs lines
2331-2336, 2396-2401, 6762-6767, and 6796-6801 with the accessor and return its
Effect; at lines 11635-11640, use the accessor and wrap the returned effect in
parsed_clause as currently required.

In `@crates/engine/src/parser/oracle_effect/sequence.rs`:
- Around line 5308-5326: Document this deferred limitation at the
bind_control_clause seam: although moved_object is correctly None for this AST
shape, the preceding Effect::SearchLibrary in defs provides the searched player
filter, which can identify the found card’s owner for CR 108.3. Add a concise
note here or to the deferred-work list explaining that opponent-library searches
currently cannot use this owner antecedent and may resolve only to ContextPlayer
or fail closed.

In `@crates/engine/src/parser/oracle_effect/tests.rs`:
- Around line 29864-29992: Add a focused destination-layer unit test alongside
return-destination tests, using strip_return_destination_ext (or its remainder
variant) to parse “it to the battlefield under their control” and assert the
destination control is Some(ControlClausePossessor::TheirAnaphor). Keep this
test independent of parse_effect_chain and bind_control_clause so it
specifically covers parse_leading_control_clause’s fallback recognition.

In `@crates/engine/src/parser/oracle_ir/ast.rs`:
- Around line 220-271: Complete the three-state contract for the remaining
entry-controller fields: change ContinuationAst::DigFromAmong.enters_under,
ContinuationAst::RevealUntilKept.enters_under, and
PutImperativeAst::Manifest.enters_under from Option<ControllerRef> to
EntersUnderSpec, and update their sequence-lowering/parsing paths to preserve
unbound third-person control clauses as UnboundAnaphor instead of defaulting.
Reuse EntersUnderSpec’s existing helpers and ensure all constructors and
consumers handle Default, Override, and UnboundAnaphor consistently.

In `@crates/engine/src/parser/oracle_nom/enters_under.rs`:
- Around line 325-354: Update bind_control_clause to remove the tuple wildcard
`(p, _)` and enumerate each currently unsupported ControlClausePossessor variant
explicitly, returning UnboundAnaphor for those cases. Preserve the existing You,
Owner, TheirAnaphor, and ThatPlayerDemonstrative behavior while making the match
exhaustive so adding a new possessor variant produces a compile-time error.
🪄 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: 416d9312-f59c-4081-bf6b-12ba184477f1

📥 Commits

Reviewing files that changed from the base of the PR and between fd6c14f and 2b25293.

⛔ Files ignored due to path filters (2)
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap is excluded by !**/*.snap, !**/snapshots/**
📒 Files selected for processing (11)
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle_effect/lower.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/parser/oracle_ir/ast.rs
  • crates/engine/src/parser/oracle_nom/enters_under.rs
  • crates/engine/src/parser/oracle_nom/mod.rs
  • crates/engine/tests/fixtures/integration_cards.json
  • crates/engine/tests/integration/issue_6691_enters_under_their_control.rs
  • crates/engine/tests/integration/main.rs

Comment on lines +3727 to 3741
// CR 110.2a (docs/MagicCompRules.txt:618): ALWAYS call the
// chain patcher, with the COLLAPSED reference. The patcher
// rewrites the existing destination and tapped flag
// UNCONDITIONALLY and the controller only behind its own
// `is_some()` guard, so skipping the whole call on an unbound
// anaphor would silently drop the DESTINATION and TAPPED
// patches as well. Passing `None` reuses that existing guard to
// no-op exactly the controller patch.
apply_search_destination_to_ability_chain(
previous,
destination,
enter_tapped,
enters_under.clone(),
enters_under.as_controller_ref(),
);
}

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 | 🏗️ Heavy lift

The SearchDestination fail-closed path only replaces the new effect, not the surrounding assembly. enters_under.unbound_possessor() swaps the constructed ChangeZone for an Effect::unimplemented, but the rest of the arm still runs as though a real move were emitted — so an unbound "under their control" can produce a gap node plus an executable wrong-controller move and an attach rider bound to a result that never exists.

  • crates/engine/src/parser/oracle_effect/sequence.rs#L3727-L3741: on the unbound path, stop patching the pre-existing library→battlefield ChangeZone in previous (or neutralize it), so no wrong-controller move survives alongside the gap.
  • crates/engine/src/parser/oracle_effect/sequence.rs#L3770-L3781: skip the forward_result / Effect::Attach wiring when put_effect is the unimplemented fragment, or fold the attach clause into that fragment.
📍 Affects 1 file
  • crates/engine/src/parser/oracle_effect/sequence.rs#L3727-L3741 (this comment)
  • crates/engine/src/parser/oracle_effect/sequence.rs#L3770-L3781
🤖 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/sequence.rs` around lines 3727 - 3741,
Handle the unbound enters_under.possessor() case as a failed assembly: at
crates/engine/src/parser/oracle_effect/sequence.rs:3727-3741, stop or neutralize
the existing library-to-battlefield ChangeZone in previous before
apply_search_destination_to_ability_chain so no executable wrong-controller move
remains; at crates/engine/src/parser/oracle_effect/sequence.rs:3770-3781, skip
forward_result and Effect::Attach wiring when put_effect is the
Effect::unimplemented fragment, or fold the attach behavior into that fragment.

Comment on lines +59 to +143
#[test]
fn jailbreak_returns_the_permanent_under_its_owners_control() {
let Some(db) = load_db() else {
eprintln!("skipping: integration card fixture not available");
return;
};

let mut scenario = GameScenario::new_n_player(3, 7);
scenario.at_phase(Phase::PreCombatMain);
let p2 = PlayerId(2);

let jailbreak = scenario.add_real_card(P0, "Jailbreak", Zone::Hand, db);
// The victim: owned by P1, sitting in P1's graveyard.
let victim = scenario.add_real_card(P1, "Grizzly Bears", Zone::Graveyard, db);
// A decoy permanent card in a DIFFERENT opponent's graveyard. If the
// binding were "an opponent" (a class, CR 102.2 @ :252) rather than the
// moved card's owner, the seat this resolves to would be ambiguous.
let _decoy = scenario.add_real_card(p2, "Grizzly Bears", Zone::Graveyard, db);
// Jailbreak costs {1}{W}; seed the pool so the cast is about the effect,
// not about mana.
scenario.with_mana_pool(
P0,
vec![
ManaUnit::new(ManaType::White, ObjectId(9_999), false, vec![]),
ManaUnit::new(ManaType::Colorless, ObjectId(9_999), false, vec![]),
],
);

let mut runner = scenario.build();
rehydrate_game_from_card_db(runner.state_mut(), db);

// FIXTURE-INTEGRITY GUARD. `Effect::ChangeZone.enters_under` is
// `skip_serializing_if = "Option::is_none"`, so a fixture entry captured
// from a PRE-FIX `card-data.json` deserializes as `enters_under: None` —
// i.e. the unfixed AST — and every assertion below would then be measuring
// the old behaviour while appearing to pass. Fail loudly instead.
{
let face = db
.get_face_by_name("Jailbreak")
.expect("Jailbreak present in the integration fixture");
let dump = format!("{face:?}");
assert!(
dump.contains("ParentTargetOwner"),
"stale fixture: Jailbreak's parsed ability must carry \
enters_under: Some(ParentTargetOwner); regenerate \
crates/engine/tests/fixtures/integration_cards.json"
);
}

let outcome = runner
.cast(jailbreak)
.target_object(victim)
// Jailbreak's delayed "up to one target" trigger is optional; P0's
// graveyard is empty so it has no legal target either way.
.decline_optional()
.resolve();
let state = outcome.state();

// Reach guard (foot-gun #6): the move must actually have HAPPENED. Without
// this, a fizzle or an illegal-target failure would make the controller
// assertion below pass vacuously.
assert!(
state.objects.contains_key(&victim),
"the returned card must still be a live object"
);
assert_eq!(
zone_of(state, victim),
Some(Zone::Battlefield),
"Jailbreak must actually return the card to the battlefield"
);
// Reach guard: ownership is what LICENSES the binding, so pin it.
assert_eq!(
state.objects[&victim].owner, P1,
"fixture invariant: the victim must be OWNED by P1"
);

// CR 110.2a resolution guard (see the module docstring for why this is a
// reach guard and not a revert-failing discriminator).
assert_eq!(
state.objects[&victim].controller, P1,
"CR 110.2a: the bound ParentTargetOwner must RESOLVE (not raise \
InvalidParam) and yield the card's owner P1 — not the caster P0, \
not the third seat P2"
);
}

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

Add a revert-failing runtime scenario.

This test explicitly passes with the pre-fix AST, so it cannot catch a regression in controller binding. Add a production-pipeline scenario where the default controller differs from the named player—such as The Beamtown Bullies’ “that player’s control”—and assert the resulting controller.

As per path instructions, regression tests must exercise the failure path the fix prevents through the production pipeline.

🤖 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_6691_enters_under_their_control.rs`
around lines 59 - 143, Add a separate integration test using a production card
ability with a named-player controller binding, such as The Beamtown Bullies’
“that player’s control,” where the default controller differs from the
referenced player. Build and resolve the scenario through the normal card
database and game runner pipeline, then assert the permanent enters under the
named player’s control and that the relevant effect does not revert.

Source: Path instructions

@matthewevans matthewevans self-assigned this Jul 30, 2026
Resolve maintainer-caused merge conflicts by retaining both main's Notion Thief fixture/registration and phase-rs#6691's Jailbreak fixture/registration.

Co-authored-by: Lcola98 <75585494+keloide@users.noreply.github.com>
@matthewevans matthewevans removed their assignment Jul 30, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer conflict port for a36ca3c7766dc3118fd7fba4cf800a28af17a4ed has been reviewed clean. Required CI is still running on this current head; approval and merge-when-ready enqueue will resume when it settles.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 6 card(s), 7 signature(s) (baseline: main daf5c1df7b2f)

🟢 Added (2 signatures)

  • 2 cards · ➕ ability/change_zone_enters_under_anaphor · added: change_zone_enters_under_anaphor
    • Affected (first 3): Plague Reaver, Turtle Tracks
  • 1 card · ➕ ability/change_zone_enters_under_anaphor · added: change_zone_enters_under_anaphor (kind=activated)
    • Affected (first 3): The Beamtown Bullies

🔴 Removed (3 signatures)

  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (from=library, target=any target, to=battlefield)
    • Affected (first 3): Turtle Tracks
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (target=self, to=battlefield)
    • Affected (first 3): Plague Reaver
  • 1 card · ➖ ability/TargetOnly · removed: TargetOnly (kind=activated, target=opponent)
    • Affected (first 3): The Beamtown Bullies

🟡 Modified fields (2 signatures)

  • 2 cards · 🔄 ability/ChangeZone · changed field enters_under: ScopedPlayer
    • Affected (first 3): Gerrymandering, Thieves' Auction
  • 1 card · 🔄 ability/ChangeZone · changed field enters_under: ParentTargetOwner
    • Affected (first 3): Jailbreak

@matthewevans

Copy link
Copy Markdown
Member

Maintainer CI repair is pushed on current head 126fc0c4b34fb869b901aa8e4e514a571d9fecda: it factors the Clippy-reported parser-table type and refreshes the five deterministic SearchDestination.enters_under snapshots from null to "Default". Required CI is queued on this head; approval and merge-when-ready enqueue remain held until it completes.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
crates/engine/src/parser/oracle_effect/lower.rs (2)

6754-6768: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Consume battlefield riders after the control clause.

After parse_leading_control_clause succeeds, this path immediately returns after consuming one space. Valid forms such as to the battlefield under their control face down and tapped therefore leave the riders in the remainder and return face_down: false / enter_tapped: false. The other destination path already scans riders after control; reuse that logic here.
As per path instructions, engine destination parsing must remain composable and rules-correct across rider orderings.

🤖 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/lower.rs` around lines 6754 - 6768,
Update the battlefield return path following parse_leading_control_clause to
consume and apply trailing battlefield riders such as face down and tapped,
including valid rider orderings, before constructing ReturnDestination. Reuse
the existing rider-scanning logic from the other destination path, preserving
the remaining input and setting face_down and enter_tapped correctly.

Source: Path instructions


6639-6662: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve control clauses after enter-with-counters suffixes.

Control parsing only runs before parse_with_counters_suffix_spanned. For valid text such as with two stun counters under their control, the counter parser consumes the prefix, while the trailing control clause is neither bound nor returned; the destination silently falls back to control: None, breaking CR 110.2a behavior. Parse the counter-suffix remainder for a control clause, and add an assertion for dest.control to the existing regression fixture.
As per path instructions, engine parsing must preserve rules-correct control binding across valid clause orderings.

🤖 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/lower.rs` around lines 6639 - 6662,
Update the destination parsing flow around parse_with_counters_suffix_spanned to
inspect its remaining text for a trailing control clause before finalizing the
destination. Use parse_leading_control_clause to bind the parsed player to
dest.control while preserving existing rider and suffix handling for orderings
such as counters followed by control. Extend the existing regression fixture
with an assertion that dest.control is populated.

Source: Path instructions

crates/engine/src/parser/oracle_effect/imperative.rs (1)

2052-2057: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

CR citation mismatch: "CR 110.1" doesn't describe controller ownership.

CR 110.1 defines what a permanent is ("A permanent is a card or token on the battlefield") and says nothing about controllers. The claim "only permanents have a controller" maps to CR 110.2 ("Every permanent has a controller") together with CR 108.4 ("A card doesn't have a controller unless that card represents a permanent or spell"), not CR 110.1.

📝 Suggested annotation fix
-                        // CR 110.1 (docs/MagicCompRules.txt:614): only
-                        // permanents have a controller.
+                        // CR 110.2 + CR 108.4 (docs/MagicCompRules.txt): only
+                        // permanents have a controller.
                         enters_under: EntersUnderSpec::Default,

Same mis-citation recurs at Line 2080-2082 (d.zone variant) — apply the same fix there.

As per path instructions: "rules-touching code with no verified CR <number>: <description> annotation, or a CR citation whose rule body does not describe the code" is a finding, and "119 starting-life / 120 damage / 121 draw are adjacent and confused" flags exactly this class of numeric-citation risk.

Also applies to: 2080-2085

🤖 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/imperative.rs` around lines 2052 -
2057, Correct the rules citations in both `EntersUnderSpec::Default`
initializations around the `d.zone` and non-`d.zone` variants: replace the
incorrect CR 110.1 reference with citations to CR 110.2 and CR 108.4, which
establish controller ownership for permanents and the applicable card types.
Keep the existing behavior and annotation wording otherwise unchanged.

Source: Path instructions

🧹 Nitpick comments (1)
crates/engine/src/parser/oracle_effect/imperative.rs (1)

2325-2357: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fail-closed enters_under.unbound_possessor() guard duplicated 5×.

The pattern if let Some(p) = enters_under.unbound_possessor() { return Effect::unimplemented("change_zone_enters_under_anaphor", p.printed_clause()); } followed by enters_under.as_controller_ref() repeats verbatim at Line 2331-2343 (ReturnToBattlefield), Line 2396-2414 (ReturnAllToZone), Line 6802-6819/6843-6849 (lower_put_ast's two arms), and Line 11682-11688 (lower_imperative_family_ast's partition arm). All five call sites are correctly guarded (verified — none is missing the check), but the boilerplate could collapse into one helper, e.g. EntersUnderSpec::resolve_or_unimplemented() -> Result<Option<ControllerRef>, Effect>, callable with ?/match at each site.

♻️ Sketch of the consolidation (illustrative, not exhaustive)
-            if let Some(p) = enters_under.unbound_possessor() {
-                return Effect::unimplemented(
-                    "change_zone_enters_under_anaphor",
-                    p.printed_clause(),
-                );
-            }
-            Effect::ChangeZone {
-                ...
-                enters_under: enters_under.as_controller_ref(),
+            let enters_under = match enters_under.resolve_or_unimplemented("change_zone_enters_under_anaphor") {
+                Ok(v) => v,
+                Err(effect) => return effect,
+            };
+            Effect::ChangeZone {
+                ...
+                enters_under,

Also applies to: 2393-2401, 6787-6849, 11668-11688

🤖 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/imperative.rs` around lines 2325 -
2357, Consolidate the repeated fail-closed handling of
enters_under.unbound_possessor() into a single EntersUnderSpec helper that
returns either the resolved controller reference or the existing
Effect::unimplemented result. Update the ReturnToBattlefield, ReturnAllToZone,
both lower_put_ast arms, and the lower_imperative_family_ast partition arm to
use this helper while preserving the current failure key and printed clause.
🤖 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/mod.rs`:
- Around line 15812-15817: Update both comments associated with
EntersUnderSpec::Default to cite CR 110.2 instead of CR 110.1, while preserving
the existing wording and behavior.

---

Outside diff comments:
In `@crates/engine/src/parser/oracle_effect/imperative.rs`:
- Around line 2052-2057: Correct the rules citations in both
`EntersUnderSpec::Default` initializations around the `d.zone` and non-`d.zone`
variants: replace the incorrect CR 110.1 reference with citations to CR 110.2
and CR 108.4, which establish controller ownership for permanents and the
applicable card types. Keep the existing behavior and annotation wording
otherwise unchanged.

In `@crates/engine/src/parser/oracle_effect/lower.rs`:
- Around line 6754-6768: Update the battlefield return path following
parse_leading_control_clause to consume and apply trailing battlefield riders
such as face down and tapped, including valid rider orderings, before
constructing ReturnDestination. Reuse the existing rider-scanning logic from the
other destination path, preserving the remaining input and setting face_down and
enter_tapped correctly.
- Around line 6639-6662: Update the destination parsing flow around
parse_with_counters_suffix_spanned to inspect its remaining text for a trailing
control clause before finalizing the destination. Use
parse_leading_control_clause to bind the parsed player to dest.control while
preserving existing rider and suffix handling for orderings such as counters
followed by control. Extend the existing regression fixture with an assertion
that dest.control is populated.

---

Nitpick comments:
In `@crates/engine/src/parser/oracle_effect/imperative.rs`:
- Around line 2325-2357: Consolidate the repeated fail-closed handling of
enters_under.unbound_possessor() into a single EntersUnderSpec helper that
returns either the resolved controller reference or the existing
Effect::unimplemented result. Update the ReturnToBattlefield, ReturnAllToZone,
both lower_put_ast arms, and the lower_imperative_family_ast partition arm to
use this helper while preserving the current failure key and printed clause.
🪄 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: ae892d00-44e6-4cc1-a1ca-88756500c54c

📥 Commits

Reviewing files that changed from the base of the PR and between 2b25293 and 126fc0c.

⛔ Files ignored due to path filters (5)
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aangs_journey_ir.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__analyze_the_pollen_ir.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap is excluded by !**/*.snap, !**/snapshots/**
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap is excluded by !**/*.snap, !**/snapshots/**
📒 Files selected for processing (6)
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle_effect/lower.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/tests/fixtures/integration_cards.json
  • crates/engine/tests/integration/main.rs

Comment on lines 15812 to +15817
target,
origin,
destination: Zone::Hand,
enters_under: None,
// CR 110.1 (docs/MagicCompRules.txt:614): only
// permanents have a controller.
enters_under: EntersUnderSpec::Default,

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
sed -n '605,625p' docs/MagicCompRules.txt

Repository: phase-rs/phase

Length of output: 219


🏁 Script executed:

fd -a 'MagicCompRules.txt' .

Repository: phase-rs/phase

Length of output: 152


🏁 Script executed:

#!/bin/bash
fd -a 'MagicCompRules.txt' .

Repository: phase-rs/phase

Length of output: 152


🏁 Script executed:

#!/bin/bash
set -euo pipefail
find . -name 'MagicCompRules.txt' -o -name 'magiccomprules.txt'

Repository: phase-rs/phase

Length of output: 152


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- source around first site ---'
sed -n '15800,15825p' crates/engine/src/parser/oracle_effect/mod.rs

echo
echo '--- source around second site ---'
sed -n '15840,15860p' crates/engine/src/parser/oracle_effect/mod.rs

echo
echo '--- search for citation text ---'
rg -n "only permanents have a controller|CR 110\.1|controller" crates docs . -g '!target'

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '15800,15825p' crates/engine/src/parser/oracle_effect/mod.rs
printf '\n---\n'
sed -n '15840,15860p' crates/engine/src/parser/oracle_effect/mod.rs
printf '\n---\n'
rg -n "only permanents have a controller|CR 110\.1|controller" crates . -g '!target' -g '!target/**'

Repository: phase-rs/phase

Length of output: 50371


🌐 Web query:

Magic Comprehensive Rules 110.1 controller permanents only have a controller

💡 Result:

According to the Magic: The Gathering Comprehensive Rules, Rule 110.1 defines what a permanent is, while the specific rule stating that every permanent has a controller is Rule 110.2 [1][2]. Rule 110.1 states: "A permanent is a card or token on the battlefield. A permanent remains on the battlefield indefinitely. A card or token becomes a permanent as it enters the battlefield and it stops being a permanent as it’s moved to another zone by an effect or rule." [1][3] Rule 110.2 explicitly addresses controllers, stating: "A permanent’s owner is the same as the owner of the card that represents it (unless it’s a token; see rule 111.2). A permanent’s controller is, by default, the player under whose control it entered the battlefield. Every permanent has a controller." [1][4]

Citations:


Use CR 110.2 here
CR 110.1 covers permanents; Every permanent has a controller is in CR 110.2. Update both EntersUnderSpec::Default notes to cite the correct rule.

🤖 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/mod.rs` around lines 15812 - 15817,
Update both comments associated with EntersUnderSpec::Default to cite CR 110.2
instead of CR 110.1, while preserving the existing wording and behavior.

@matthewevans matthewevans self-assigned this Jul 30, 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.

Blocked — the new fail-closed representation is bypassed on the SearchDestination assembly path, and the current test suite does not exercise that path.

🔴 Blocker

apply_clause_continuation still patches a pre-existing library-to-battlefield ChangeZone with enters_under.as_controller_ref() before it creates the new Effect::unimplemented node for an unbound anaphor. Evidence: crates/engine/src/parser/oracle_effect/sequence.rs:3727-3740 calls apply_search_destination_to_ability_chain with None; its implementation at crates/engine/src/parser/oracle_effect/sequence.rs:5048-5073 leaves the existing controller untouched while still rewriting destination/tapped. The later unbound branch at crates/engine/src/parser/oracle_effect/sequence.rs:3747-3781 only substitutes the newly appended node and still adds forward_result/Attach when present. That leaves an executable default-controller move alongside the reported change_zone_enters_under_anaphor gap, so the parser reports an honest failure while the chain can still perform the wrong move.

The existing failure-closed test does not cover this production branch: crates/engine/src/parser/oracle_effect/tests.rs:30079-30091 tests a direct return (lower_put_ast), while the affected path is the SearchLibrary continuation. The integration test also explicitly documents that it passes with the pre-fix AST at crates/engine/tests/integration/issue_6691_enters_under_their_control.rs:18-37.

Suggested fix: make EntersUnderSpec::UnboundAnaphor abort or replace the entire search-destination assembly before apply_search_destination_to_ability_chain mutates its predecessor, including the attachment continuation. Add a parser/production-path regression that exercises an unbound search-destination clause and proves no executable ChangeZone or attachment survives.

🟡 Non-blocking

bind_control_clause ends with (p, _) at crates/engine/src/parser/oracle_nom/enters_under.rs:353. The module otherwise deliberately uses exhaustive controller mappings; enumerate the remaining anaphor/antecedent combinations so a future ControlClausePossessor cannot silently become an unexplained gap.

The current parse-diff sticky comment reports six affected cards, while the PR body claims seven and names Endless Whispers as an additional honest gap. Reconcile that claim with the current artifact before re-review; no changed signature in the sticky evidence identifies Endless Whispers. CI is green, but it does not resolve the assembly-path defect above.

Recommendation: request changes. Repair the whole SearchDestination fail-closed assembly and cover that exact path before re-review.

@matthewevans matthewevans added the bug Bug fix label Jul 30, 2026
@matthewevans matthewevans removed their assignment Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

parser: under their control binding unparsed — permanents enter under the wrong controller (CR 110.2a)

3 participants