Skip to content

fix(parser): stop failing open on an unparseable subject, bind targeting compound subjects (#6965) - #7003

Merged
matthewevans merged 3 commits into
mainfrom
ship/6965-compound-subject-fail-closed
Aug 4, 2026
Merged

fix(parser): stop failing open on an unparseable subject, bind targeting compound subjects (#6965)#7003
matthewevans merged 3 commits into
mainfrom
ship/6965-compound-subject-fail-closed

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 4, 2026

Copy link
Copy Markdown
Member

Closes #6965.

The defect

An unparseable subject fell open to TargetFilter::Any — a filter that matches unconditionally. The card then "parsed successfully" and counted as SUPPORTED in coverage while doing something the Oracle text never says. That is worse than an honest gap, because a gap is visible and a fail-open is not.

Three commits

1. Stop failing open on an unparseable subject. Removes the Any fallback; an unbindable subject now yields an honest Effect::Unimplemented { name: "unbound_subject" }.

2. Bind both choosers of a targeting compound subject. The shape "You and <subject> each <verb>" has 20 corpus members in AtomicCards.json (34,868 cards); 14 have a targeting second conjunct. Two seams, both general:

  • A new second-subject axis (parse_possessive_actor_each_second_subject) built from nom take_until/tag, delegating the conjunct to the existing subject::parse_subject_application — already the authority for "target X's controller". Gated on both halves, so it never fabricates a target. The "target <filter>'s controller/owner each" axis is general over any object noun and any body.
  • Recipient binding for slot-less effects. Effect::Choose has no recipient field, so bind_recipient_without_recipient_slot stamps AbilityDefinition.player_scope, reusing the existing player_scope_from_parent_target_subject lift. Total and fail-closed: anything it cannot name returns false.

Newly supported: Life at Stake, Infernal Offering. Mana Clash and Expert-Level Safe deliberately stay fail-closed — their conjunct is target opponent, and no PlayerFilter names a targeted player (PlayerFilter::Opponent would make every opponent act in multiplayer).

3. Repin two guards that were asserting the fail-open. Keen Duelist and Angel of Destiny both pinned shapes only reachable through the Any fallback, and both said so in their own doc comments (RevealTop { player: Any }; "the damaged player still doesn't gain life"). Their intent — never launder an unresolved subject into a concrete recipient — is preserved and strengthened: an Unimplemented gap satisfies it more completely than the old defaults did. Both now assert the gap names the subject as the unbound part and quotes the conjunct that caused it, so a clause failing elsewhere can't pass as fail-closed coverage.

Coverage

Measured with two real oracle-gen runs in-worktree (99 MB emitted ASTs diffed by card), fix vs. both seams neutered:

seams neutered : 32028/34868 (91.9%)
with the fix   : 32030/34868 (91.9%)
NEWLY SUPPORTED: ['infernal offering', 'life at stake']
NEWLY BROKEN   : []

Heads-up on the ratchet: commit 1 removes the fail-open globally, which was previously measured at roughly −88 supported cards (92.1% → 91.9%). Those cards were never actually working — they were matching unconditionally. This may need a ratchet waiver or a baseline refresh; flagging rather than adjusting anything.

Verification

  • cargo fmt --all clean; cargo clippy -p phase-engine --all-targets -- -D warnings exit 0
  • Engine lib suite: 18,487 passed, 0 failed
  • Integration suite: 4,487 passed, 0 failed
  • Every discriminating test watched go red with its seam neutered, including the fail-closed guard, which was probed separately by adding the naive Opponent → PlayerFilter::Opponent mapping and confirming it reds
  • CR numbers grep-verified in docs/MagicCompRules.txt: CR 109.4, CR 109.5, CR 115.1, CR 601.2c, CR 608.2c, CR 608.2d, CR 611.2c

Deliberately not in scope

"That player" as the damage/reveal-event player. Effect::GainLife and Effect::RevealTop do have recipient slots, so adding arms to rewrite_recipient_on_link looks like a two-line extension — but Angel of Destiny's second conjunct would bind to TargetFilter::ScopedPlayer, which in a DamageDone trigger with no player_scope falls back to the controller. The damaged player would not gain life and the caster would gain twice: a silent misparse, strictly worse than the honest gap. Needs a real binding for the event player first.

Simultaneous hidden choice. "Secretly choose" implies neither player sees the other's number. Effect::Choose raises one WaitingFor::NamedChoice at a time, so P1 answers after seeing that P0 answered (never what). No simultaneous-hidden-choice primitive exists in the engine; adding one is its own unit.

Summary by CodeRabbit

  • Bug Fixes

    • Improved parsing for effects involving compound subjects, including a creature’s controller or owner.
    • Preserved declared targets and correctly bound players in effects with or without recipient slots.
    • Prevented unsupported or ambiguous subjects from being treated as unrestricted targets.
    • Correctly handles player phrases such as “that opponent,” “you may,” and “they each.”
  • Tests

    • Added regression coverage for Life at Stake, Wand of Orcus, and related compound-subject scenarios.
    • Updated unsupported cases to fail safely without applying unintended effects.

`parse_subject_application` returning `None` was replaced with
`SubjectApplication { affected: TargetFilter::Any, .. }` at both
subject-predicate sites that re-derive a subject. `TargetFilter::Any`
matches unconditionally (`game/filter.rs`), so a parse FAILURE produced a
BOARD-WIDE effect — the grant landed on every permanent the controller
had, lands and artifacts included, while coverage still reported the card
as supported.

Measured on the emitted AST (`oracle-gen`, 35,657 faces): 49 sites over 48
cards applied a modification through a fabricated `Any` filter. All 49
originate at those two sites — removing the default takes them to 0.

Fail closed by construction, not by patch:

* `SubjectPhraseAst.affected` becomes `Option<TargetFilter>`, so "the
  subject grammar could not bind this phrase" is a state the type holds
  and the fail-open is unrepresentable. Sibling of
  `EntersUnderSpec::UnboundAnaphor`.
* The one consumer that applies the subject filter (the
  `ImperativeFallback` arm of `lower_subject_predicate_ast`) emits
  `Effect::unimplemented("unbound_subject", <whole printed clause>)`.
  The `Continuous`/`Become`/`Restriction` arms never read it, so failing
  closed there would have been inert over-firing — measured at 195 extra
  cards made red for no correctness gain.
* The decision, and the options not taken, are recorded on
  `subject::UNBOUND_SUBJECT_GAP`.

Then parse the construction the issue names. CR 611.2c: one effect naming
several subjects determines each part's object set independently, so a
compound subject is the UNION of its conjuncts.
`parse_conjoined_subject_application` splits at a word-boundary `and` and
parses each conjunct with the ordinary single-subject grammar, recursing
on the right for N-ary lists; `merge_or_filters` flattens. This replaces
the single compound arm hardcoded to the literal phrase "you and
permanents you control", which it reproduces byte-for-byte. It runs last,
so it can only convert an unbound subject into a bound union.

A conjunct must be non-targeting and *unionable*. Two rejection classes,
both found by measurement:

* a non-discriminating filter (`Any`, or a default `TypedFilter`) —
  Model of Unity's unmodelled "who voted for a choice you voted for"
  collapsed to one, and `Or[Controller, <default>]` let every player
  scry: the same fail-open wearing an `Or`;
* an event-context anaphor, which resolves through the target/binding
  channel — `Or[TriggeringSource, Typed(Zombie, You)]` granted deathtouch
  to the Zombies and not to the equipped creature, a half-applied grant
  that still reports as supported.

Failing closed also surfaced three player subjects the grammar should
always have bound, masked until now by the fabricated filter: "you may"
(controller's own permission grant — not `is_optional`, the permission is
itself the opt-in), "they each" (distributive emphasis on an already
plural pronoun), and "that opponent" (the "that player" anaphor with the
noun narrowed).

Coverage 92.1% -> 91.9%: 88 cards move from supported to unsupported,
which is what they are. The 21 sites that legitimately carry
`affected: Any` — the CR 305.1 play/plot permissions built in
`oracle_static/restriction.rs` (Omniscience, Future Sight, Bolas's
Citadel, Theater of Horrors, Fblthp, ...) — are byte-identical, and no
new `Any` site is created anywhere in the corpus.
Life at Stake — "You and target creature's controller each secretly choose a
number 0 or greater." — became an honest `Unimplemented("unbound_subject")`
when the fail-open closed: `parse_conjoined_subject_application`'s CR 611.2c
union declines a TARGETING conjunct, because unioning it into one filter would
lose the target binding.

The union is the wrong shape for this class. `try_parse_compound_subject_each`
already distributes a shared predicate across two recipients as a `sub_ability`
chain, which keeps each recipient separately bound. Two things stopped it from
covering the class.

* **The conjunct names its player through an object.** New second-subject axis
  `parse_possessive_actor_each_second_subject` delegates the conjunct to the
  single-subject grammar (`parse_subject_application`) — the established
  authority for "target <filter>'s controller/owner", which resolves the actor
  to `ParentTargetController`/`ParentTargetOwner` (CR 109.4) while preserving
  the announced object as the ability's target (CR 115.1). It is gated on BOTH
  halves being present, so it never fabricates a target slot. The distributor
  then emits that target as a leading `Effect::TargetOnly` head — the same
  slot-only declaration the single-subject grammar emits — so the recipient
  reference (and the card's later "exile that creature" anaphor) has a slot to
  resolve against (CR 601.2c). The prefix parsers now return a
  `CompoundSubjectPrefix` rather than a widening tuple, and the shared
  "you"/"~" first-subject alt is factored into one combinator.

* **`Effect::Choose` has no recipient slot.** `rewrite_recipient_on_link` bound
  recipients by writing a `TargetFilter` field, and returned `false` for every
  effect family without one. Such an effect's acting player is the resolving
  ability's controller, so the recipient belongs on the ABILITY:
  `bind_recipient_without_recipient_slot` stamps `player_scope`, the same lift
  the single-subject grammar already performs for a slot-less predicate ("its
  controller investigates" — `player_scope_from_parent_target_subject`, reused
  here rather than duplicated).

  That binding is total and FAIL-CLOSED. "you" needs no scope (CR 109.5 — the
  printed controller already is the unscoped acting player); a parent-target
  actor and a resolution-chosen player map to existing `PlayerFilter` variants;
  everything else returns `false`. In particular a TARGETED player ("you and
  target opponent each flip a coin" — Mana Clash; "… each secretly choose 1, 2,
  or 3" — Expert-Level Safe) stays `Unimplemented`, because no `PlayerFilter`
  names one: `PlayerFilter::Opponent` would make EVERY opponent act in a
  multiplayer game. A regression test pins that, and fails against the
  over-broad mapping.

The chunk-splitter guard (`remainder_trimmed_starts_with_compound_subject_each`)
delegates to the same axis combinator, so the two sites cannot drift.

Measured on the emitted AST (`oracle-gen`, 35,657 faces): 32028 → 32030 fully
implemented, zero cards newly broken. The two are Life at Stake and Infernal
Offering ("You and that player each sacrifice a creature" — `Effect::Sacrifice`
is likewise slot-less, and its conjunct is the opponent the preceding "Choose an
opponent." picked).

The new integration test drives the real parse → cast → resolution pipeline and
asserts the only thing that matters at runtime: the number prompts go to P0 then
P1, not twice to the caster. Pre-fix it observes `[]`.
…he fail-open (#6965)

Both tests asserted shapes that were only reachable because an unparseable
subject fell open to a filter matching unconditionally, which is the defect this
issue removes. Each said so in its own doc comment.

Keen Duelist pinned `RevealTop` as the trigger root and gapped only the lose
clause — but the comment named the binding it depended on, `RevealTop { player:
Any }`. "you and target opponent each reveal" has a TARGETED player as its
second conjunct, and no PlayerFilter names one; PlayerFilter::Opponent would
make every opponent reveal in multiplayer. So the subject now fails closed and
the whole trigger is an honest unbound_subject gap. The lose clause sits behind
it, so the test no longer routes through lose_node (Parker Luck still does).

Angel of Destiny pinned `GainLife { player: Controller }` and conceded the gap
in the same breath: the damaged player never gained life. That is a half-applied
effect the caster benefits from, counted as SUPPORTED in coverage — a silent
misparse rather than a visible gap. It reached GainLife at all only because the
unbindable subject fell open.

Neither original intent is lost. Both existed to stop an unresolved subject from
being laundered into a concrete recipient, and an Unimplemented gap satisfies
that more completely than a Controller default did. Both now assert the gap
names the SUBJECT as the unbound part and quotes the conjunct that caused it, so
a clause failing elsewhere no longer passes as fail-closed coverage. Both carry
a forward-red note: binding a targeted player, or "that player" as the
damage-event player, will red them, which is the prompt to assert the real
shape.

Integration suite: 4487 passed, 0 failed.
@matthewevans
matthewevans enabled auto-merge August 4, 2026 22:42
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The parser now supports possessive actor compound subjects, represents unbound subjects explicitly, rejects unsafe conjuncts, and lowers unresolved subjects to Effect::Unimplemented. Tests cover chooser binding, compound unions, and fail-closed behavior.

Changes

Compound subject parsing

Layer / File(s) Summary
Subject binding and fail-closed AST
crates/engine/src/parser/oracle_ir/ast.rs, crates/engine/src/parser/oracle_effect/subject.rs
Subject parsing preserves affected: None when binding fails. Compound subjects produce union filters only for supported non-targeting conjuncts.
Possessive actor compound effects
crates/engine/src/parser/oracle_effect/mod.rs, crates/engine/src/parser/oracle_effect/sequence.rs, crates/engine/src/parser/oracle_effect/tests.rs
Possessive actors such as a target creature’s controller or owner are parsed. Declared targets, recipient filters, and chooser scopes are preserved.
Safe effect lowering
crates/engine/src/parser/oracle_effect/mod.rs
Unbound subjects produce Effect::Unimplemented. Subject rebinding and injection no longer fabricate filters.
Integration regression coverage
crates/engine/tests/integration/*
Regression tests cover Angel of Destiny, Keen Duelist, Life at Stake, Wand of Orcus, and Lazotep Plating parsing and resolution behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant OracleText
  participant CompoundSubjectParser
  participant PossessiveActorParser
  participant EffectBuilder
  OracleText->>CompoundSubjectParser: parse compound subject
  CompoundSubjectParser->>PossessiveActorParser: parse target filter's controller or owner
  PossessiveActorParser-->>CompoundSubjectParser: return filters and declared target
  CompoundSubjectParser->>EffectBuilder: build distributed effect
  EffectBuilder-->>EffectBuilder: prepend Effect::TargetOnly
Loading

Possibly related PRs

Suggested labels: bug

Suggested reviewers: lgray, 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 clearly describes the fail-open parser fix and compound-subject binding changes.
Linked Issues check ✅ Passed The changes replace broad subject fallbacks with unimplemented gaps and add conservative compound-subject parsing as required by issue #6965.
Out of Scope Changes check ✅ Passed The parser changes and regression tests directly support the linked issue and stated objectives; no unrelated code changes are evident.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/6965-compound-subject-fail-closed

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.

🔧 ast-grep (0.45.0)
crates/engine/src/parser/oracle_effect/mod.rs

ast-grep timed out on this file


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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: 8

Caution

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

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

2676-2700: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep the "you may" subject out of continuous effects.

try_parse_additive_type_continuous_clause returns early only on the exact player subject "you", so accepting "you may" in parse_subject_application lets the same player subject reach build_continuous_clause. Exclude "you may" at the continuous clause guard, or route permission grants through a separate subject path that cannot classify imperative one-shots as P/T or keyword modifications.

🤖 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/subject.rs` around lines 2676 - 2700,
The newly added `"you may"` case in parse_subject_application must not flow into
continuous-effect handling. Update try_parse_additive_type_continuous_clause or
its guard to explicitly reject the exact `"you may"` subject while preserving
support for ordinary `"you"` and other player-subject forms; do not alter the
permission-grant SubjectApplication semantics.
crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs (1)

315-356: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Exercise fail-closed effects through the runtime pipeline.

Both tests inspect lowered AST nodes only. Neither test resolves the affected ability. A later execution-path regression can partially apply an unbound_subject effect while both tests remain green.

  • crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs#L315-L356: trigger Angel of Destiny through combat damage and resolve it. Assert that the unsupported clause grants life to neither player.
  • crates/engine/tests/integration/parker_luck.rs#L139-L166: trigger Keen Duelist at upkeep, select the opponent, and resolve it. Assert that the unsupported clause performs neither reveal nor life loss.

As per path instructions, “a parser AST shape test does NOT prove runtime semantics.”

🤖 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_6381_benevolent_offering_repeat_opponent.rs`
around lines 315 - 356, The tests currently inspect lowered AST nodes instead of
exercising runtime behavior. In
crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs:315-356,
update the Angel of Destiny test to trigger the ability through combat damage
and resolve it, asserting the unsupported life-gain clause grants life to
neither player. In crates/engine/tests/integration/parker_luck.rs:139-166,
update the Keen Duelist test to trigger at upkeep, select the opponent, and
resolve it, asserting the unsupported clause performs neither reveal nor life
loss.

Source: Path instructions

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

9411-9416: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

State that "perpetually" is outside the Comprehensive Rules.

The comment calls perpetually the "Alchemy permanence marker". A recorded preference for this repository asks that perpetually be identified explicitly as a digital-only Alchemy extension outside the Comprehensive Rules, so a reader does not look for a CR section that defines it. Add that qualifier.

📝 Proposed comment tweak
     /// Fixture is By Elspeth's Command mode 2, VERBATIM. `"It perpetually"` is
     /// the real stranded-adverb shape: `find_predicate_start` splits at the verb
-    /// `gets`, leaving the Alchemy permanence marker on the subject side, which
-    /// no subject arm binds. Before the fix this clause emitted a static with
+    /// `gets`, leaving `perpetually` on the subject side, which no subject arm
+    /// binds. `perpetually` is a digital-only Alchemy extension and is not
+    /// defined anywhere in the Comprehensive Rules, so no CR reference applies
+    /// to it. Before the fix this clause emitted a static with
     /// `affected: TargetFilter::Any` — the grant landed on every permanent.

Based on learnings: treat "perpetually" as a digital-only Alchemy extension outside the Comprehensive Rules; explicitly identify it as such in the comment.

🤖 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/subject.rs` around lines 9411 - 9416,
Update the fixture comment near the “perpetually” reference to identify it
explicitly as a digital-only Alchemy extension outside the Comprehensive Rules,
while preserving the existing explanation of its subject-side parsing behavior.

Source: Learnings

🤖 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 18661-18676: Update bind_recipient_without_recipient_slot to
return false when def.player_scope is already set, before assigning the scope
derived from filter. Preserve the existing OriginalController handling, scope
lookup, and assignment only for definitions without an existing player scope so
rewrite_recipient_chain fails closed instead of overwriting iteration scope.
- Around line 18323-18346: Update parse_possessive_actor_each_second_subject and
its caller to preserve the original-case subject span and thread the enclosing
ParseContext through parsing instead of using ParseContext::default(). Parse the
possessive controller/owner target with a tentative context, return or commit
the resulting context as required, and ensure subtype-bearing targets retain
their original casing while preserving relative scope.

In `@crates/engine/src/parser/oracle_effect/tests.rs`:
- Around line 617-626: Update the test around parse_effect_chain to assert the
exact fail-closed result, requiring Effect::Unimplemented with name
"unbound_subject" rather than any unimplemented effect. Add a positive reach
guard covering the shared compound-subject parsing path so the test confirms
that targeted-player subjects reach the intended unbound-subject handling before
asserting player_scope is None.
- Around line 416-426: Correct the CR citations in the documented tests: remove
CR 608.2c from the comments around the compound-choice cases unless they
explicitly test resolving instructions in written order, and remove CR 109.4
from the comments around “that player” or “target opponent” cases. Replace each
removed citation with a verified rule directly describing the tested target,
controller, choice, or action behavior, while preserving citations such as CR
115.1 where they accurately describe targets.

In `@crates/engine/src/parser/oracle_ir/ast.rs`:
- Around line 196-201: Update the documentation near the subject field to remove
the claim that ImperativeFallback is the only consumer reading it. State instead
that lower_subject_predicate_ast’s ImperativeFallback arm is the only consumer
treating None as a coverage gap, while sync_subject_into_nested_shuffle_sub and
inject_subject_target treat None as nothing to rebind.

In `@crates/engine/tests/integration/wand_of_orcus_compound_subject_6965.rs`:
- Line 24: Replace the incorrect CR 301.5f citations in the test comments near
the compound-subject attachment assertions with citations matching the
implemented attachment relation: use CR 301.5 and CR 301.5a, or CR 301.5b when
the attachment is caused by an ability. Update both cited locations while
preserving the test behavior.
- Around line 99-126: Before calling advance_until_stack_empty in the Wand of
Orcus trigger test, inspect the triggered stack item's execute chain and assert
it contains Effect::Unimplemented with name "unbound_subject" plus the expected
diagnostic. Keep the existing stack_names reach guard and runtime no-Deathtouch
assertions, ensuring the negative assertions remain protected by proof that this
specific unbound-subject path was reached.
- Around line 167-182: Extend the integration test around runner and the
existing Lazotep Plating scenario with a production-path player-targeting
assertion. Have P1 attempt to target P0 while Plating is active and assert the
action is rejected because P0 has player hexproof. Keep the existing permanent
assertions and ensure the new check verifies the compound subject preserves the
“you” player binding.

---

Outside diff comments:
In `@crates/engine/src/parser/oracle_effect/subject.rs`:
- Around line 2676-2700: The newly added `"you may"` case in
parse_subject_application must not flow into continuous-effect handling. Update
try_parse_additive_type_continuous_clause or its guard to explicitly reject the
exact `"you may"` subject while preserving support for ordinary `"you"` and
other player-subject forms; do not alter the permission-grant SubjectApplication
semantics.

In
`@crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs`:
- Around line 315-356: The tests currently inspect lowered AST nodes instead of
exercising runtime behavior. In
crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs:315-356,
update the Angel of Destiny test to trigger the ability through combat damage
and resolve it, asserting the unsupported life-gain clause grants life to
neither player. In crates/engine/tests/integration/parker_luck.rs:139-166,
update the Keen Duelist test to trigger at upkeep, select the opponent, and
resolve it, asserting the unsupported clause performs neither reveal nor life
loss.

---

Nitpick comments:
In `@crates/engine/src/parser/oracle_effect/subject.rs`:
- Around line 9411-9416: Update the fixture comment near the “perpetually”
reference to identify it explicitly as a digital-only Alchemy extension outside
the Comprehensive Rules, while preserving the existing explanation of its
subject-side parsing behavior.
🪄 Autofix

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: d74acc83-1aa1-431c-8f0a-eec3cb426fdf

📥 Commits

Reviewing files that changed from the base of the PR and between b654513 and 7b50b70.

⛔ Files ignored due to path filters (1)
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__karn_legacy_reforged_ir.snap is excluded by !**/*.snap, !**/snapshots/**
📒 Files selected for processing (10)
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/src/parser/oracle_effect/subject.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/parser/oracle_ir/ast.rs
  • crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs
  • crates/engine/tests/integration/life_at_stake_both_choosers_6965.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/parker_luck.rs
  • crates/engine/tests/integration/wand_of_orcus_compound_subject_6965.rs

Comment on lines +18323 to +18346
pub(super) fn parse_possessive_actor_each_second_subject(
rest: &str,
) -> Option<(usize, TargetFilter, TargetFilter)> {
let (remaining, (first_filter, second_filter)) = (
alt((
value(
TargetFilter::OriginalController,
tag::<_, _, OracleError<'_>>("you and "),
),
value(TargetFilter::SelfRef, tag("~ and ")),
)),
let (remaining, subject) = terminated(
take_until::<_, _, OracleError<'_>>(" each "),
tag::<_, _, OracleError<'_>>(" each "),
)
.parse(rest)
.ok()?;
let application =
subject::parse_subject_application(subject.trim(), &mut ParseContext::default())?;
if !matches!(
application.affected,
TargetFilter::ParentTargetController | TargetFilter::ParentTargetOwner
) {
return None;
}
let declared_target = application.target?;
Some((
rest.len() - remaining.len(),
application.affected,
declared_target,
))
}

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
# Confirm the possessive-suffix subject arm is ctx-dependent, and that it is the
# only producer of ParentTargetController/Owner WITH a declared target.
set -euo pipefail

rg -n -C12 --type=rust "ParentTargetController," crates/engine/src/parser/oracle_effect/subject.rs \
  | rg -n -C12 'parse_target_with_ctx|target: Some'

# All producers of an application with affected=ParentTargetController|Owner and a target.
ast-grep run --lang rust \
  --pattern 'SubjectApplication {
    affected: $A,
    target: Some($T),
    $$$
  }' \
  crates/engine/src/parser

# Does parse_target build Subtype filters from the passed-in casing?
ast-grep run --lang rust --pattern 'TypeFilter::Subtype($X)' crates/engine/src/parser/oracle_target.rs

Repository: phase-rs/phase

Length of output: 5996


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== oracle_effect mod relevant functions and call sites =="
sed -n '17330,18455p' crates/engine/src/parser/oracle_effect/mod.rs | cat -n | sed -n '1,130p'
sed -n '18455,18525p' crates/engine/src/parser/oracle_effect/mod.rs | cat -n

echo "== subject possessive arm =="
sed -n '2225,2285p' crates/engine/src/parser/oracle_effect/subject.rs | cat -n

echo "== target Subtype capitalization behavior =="
sed -n '3875,3905p' crates/engine/src/parser/oracle_target.rs | cat -n

echo "== parse_subject_application implementation and callers =="
rg -n -C8 --type=rust "fn parse_subject_application|parse_subject_application\\(" crates/engine/src/parser/oracle_effect

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== exact function body for possessive second subject =="
sed -n '18280,18360p' crates/engine/src/parser/oracle_effect/mod.rs | cat -n

echo "== lowercased producer call sites =="
sed -n '18360,18445p' crates/engine/src/parser/oracle_effect/mod.rs | cat -n

echo "== TextPair Subtype capitalization behavior =="
rg -n -C15 --type=rust "fn capitalize_first|capitalize_first\\(" crates/engine/src/parser/oracle_effect target.rs || true

echo "== parse_target_context signature and relative_player_scope usage =="
rg -n -C6 'fn parse_target_with_ctx|relative_player_scope|controller: Some\\(ctx|ControllerRef::ChosenPlayer' crates/engine/src/parser/oracle_effect/subject.rs

Repository: phase-rs/phase

Length of output: 10755


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== capitalized_first definitions/usages =="
rg -n -C8 --type=rust "capitalize_first|TypeFilter::Subtype\(capitalize_first" crates/engine/src/parser

echo "== parse_target_with_ctx relative_player_scope controller suffix code =="
rg -n -C5 --type=rust 'fn parse_target_with_ctx|relative_player_scope|parse_target_with_ctx\(|target that player controls|that player controls' crates/engine/src/parser/oracle_effect/oracle_target.rs crates/engine/src/parser/oracle_effect/subject.rs

echo "== try_parse_compound_subject_each exact producer path =="
sed -n '18525,18595p' crates/engine/src/parser/oracle_effect/mod.rs | cat -n

Repository: phase-rs/phase

Length of output: 45364


Thread the original case and context through the possessive second-subject parser.

Lines 18301-18304 pass subject.trim() and a fresh ParseContext::default() into parse_subject_application. The possessive-controller/owner arm parses the inner target with parse_target_with_ctx(subject, ctx), so “that player controls” loses the enclosing relative scope and falls back to ControllerRef::You. The caller and consumers also receive lowercased text, so a future subtype-bearing form would build TypeFilter::Subtype("zombie") for an Oracle subtype like “Zombie”. Keep the original-case span (for subtypes) and use a tentative/returned context for ParseContext state.

🤖 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 18323 - 18346,
Update parse_possessive_actor_each_second_subject and its caller to preserve the
original-case subject span and thread the enclosing ParseContext through parsing
instead of using ParseContext::default(). Parse the possessive controller/owner
target with a tentative context, return or commit the resulting context as
required, and ensure subtype-bearing targets retain their original casing while
preserving relative scope.

Comment on lines +18661 to +18676
fn bind_recipient_without_recipient_slot(
def: &mut AbilityDefinition,
filter: &TargetFilter,
) -> bool {
// CR 109.5: "you" — the printed controller already IS the acting player of
// an unscoped ability, so this half needs no scope. Stamping one would be a
// redundant single-player fan-out.
if matches!(filter, TargetFilter::OriginalController) {
return true;
}
let Some(scope) = distribution_recipient_player_scope(filter) else {
return false;
};
def.player_scope = Some(scope);
true
}

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

Guard def.player_scope before overwriting it.

bind_recipient_without_recipient_slot assigns def.player_scope = Some(scope) without checking the existing value. The function documents a "Total and FAIL-CLOSED" contract, but that contract only covers recipients no PlayerFilter can name. It does not cover a body that already carries its own iteration scope.

try_parse_compound_subject_each calls rewrite_recipient_chain twice on clones of one parsed body (Lines 18505-18512). If any link of that body already has a player_scope — for example a body that itself reads "each opponent s" — then half A and half B each overwrite that scope with a different recipient. The printed per-player iteration is lost and the effect resolves for the wrong set of players.

Return false when a scope is already present, so the distribution falls through to Effect::Unimplemented instead of silently rebinding.

🛡️ Proposed fail-closed guard
     if matches!(filter, TargetFilter::OriginalController) {
         return true;
     }
     let Some(scope) = distribution_recipient_player_scope(filter) else {
         return false;
     };
+    // CR 109.4: the body already declares its own per-player iteration
+    // ("each opponent <verb>s"). Overwriting it would silently replace the
+    // printed player set with this one recipient, so fail closed instead.
+    if def.player_scope.is_some() {
+        return false;
+    }
     def.player_scope = Some(scope);
     true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn bind_recipient_without_recipient_slot(
def: &mut AbilityDefinition,
filter: &TargetFilter,
) -> bool {
// CR 109.5: "you" — the printed controller already IS the acting player of
// an unscoped ability, so this half needs no scope. Stamping one would be a
// redundant single-player fan-out.
if matches!(filter, TargetFilter::OriginalController) {
return true;
}
let Some(scope) = distribution_recipient_player_scope(filter) else {
return false;
};
def.player_scope = Some(scope);
true
}
fn bind_recipient_without_recipient_slot(
def: &mut AbilityDefinition,
filter: &TargetFilter,
) -> bool {
// CR 109.5: "you" — the printed controller already IS the acting player of
// an unscoped ability, so this half needs no scope. Stamping one would be a
// redundant single-player fan-out.
if matches!(filter, TargetFilter::OriginalController) {
return true;
}
let Some(scope) = distribution_recipient_player_scope(filter) else {
return false;
};
// CR 109.4: the body already declares its own per-player iteration
// ("each opponent <verb>s"). Overwriting it would silently replace the
// printed player set with this one recipient, so fail closed instead.
if def.player_scope.is_some() {
return false;
}
def.player_scope = Some(scope);
true
}
🤖 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 18661 - 18676,
Update bind_recipient_without_recipient_slot to return false when
def.player_scope is already set, before assigning the scope derived from filter.
Preserve the existing OriginalController handling, scope lookup, and assignment
only for definitions without an existing player scope so rewrite_recipient_chain
fails closed instead of overwriting iteration scope.

Comment on lines +416 to +426
/// CR 109.4 + CR 115.1 + CR 608.2c + CR 608.2d: Life at Stake — "You and target
/// creature's controller each secretly choose a number 0 or greater."
///
/// The compound subject's second conjunct names its player THROUGH an announced
/// object target, so the parse must produce three things, not one:
/// 1. a `TargetOnly { creature }` head declaring the CR 115.1 target slot the
/// possessive reference (and the later "exile that creature" anaphor) read;
/// 2. a `Choose { NumberRange }` whose chooser is the printed controller
/// ("you", CR 109.5 — the unscoped resolver default);
/// 3. a SECOND `Choose { NumberRange }` bound to a DISTINCT chooser via
/// `player_scope: ParentObjectTargetController` (CR 109.4).

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

Correct the CR citations.

At Lines 416-426 and 515-519, remove CR 608.2c unless the test documents resolution in written order. At Lines 562-566 and 605-610, remove CR 109.4 because it defines controllers of objects, not that player or target opponent. Use a verified rule that directly describes the tested target, controller, choice, or action behavior.

CR 109.4 applies to objects on the stack or battlefield. CR 115.1 defines targets. CR 608.2c only covers following instructions in written order. (media.wizards.com)

As per path instructions, rules-touching code must use a verified CR citation whose text describes the code. Based on learnings, cite CR 608.2c only for written instructions resolved in order.

Also applies to: 515-519, 562-566, 605-610

🤖 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 416 - 426,
Correct the CR citations in the documented tests: remove CR 608.2c from the
comments around the compound-choice cases unless they explicitly test resolving
instructions in written order, and remove CR 109.4 from the comments around
“that player” or “target opponent” cases. Replace each removed citation with a
verified rule directly describing the tested target, controller, choice, or
action behavior, while preserving citations such as CR 115.1 where they
accurately describe targets.

Sources: Path instructions, Learnings

Comment on lines +617 to +626
let ability = parse_effect_chain(text, AbilityKind::Spell);
assert!(
matches!(&*ability.effect, Effect::Unimplemented { .. }),
"{text:?} must fail closed, got {:#?}",
ability.effect
);
assert_eq!(
ability.player_scope, None,
"{text:?} must not fabricate a fan-out scope"
);

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

Assert the intended fail-closed reason.

This test accepts any Effect::Unimplemented. It can pass if parsing fails before the targeted-player compound subject is reached. Assert the exact unbound_subject result and add a positive reach guard for the shared compound-subject path.

As per path instructions, negative parser assertions need a positive reach guard. The PR objective requires unbound subjects to lower as Effect::Unimplemented { name: "unbound_subject" }.

🤖 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 617 - 626,
Update the test around parse_effect_chain to assert the exact fail-closed
result, requiring Effect::Unimplemented with name "unbound_subject" rather than
any unimplemented effect. Add a positive reach guard covering the shared
compound-subject parsing path so the test confirms that targeted-player subjects
reach the intended unbound-subject handling before asserting player_scope is
None.

Source: Path instructions

Comment on lines +196 to +201
/// unrepresentable: every consumer must say what it does with `None`, and
/// the one consumer that actually reads this field
/// (`lower_subject_predicate_ast`'s `ImperativeFallback` arm, the only
/// predicate kind that applies the subject filter) fails closed to
/// `Effect::unimplemented`. Same shape, same reason, as
/// [`EntersUnderSpec::UnboundAnaphor`].

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the "one consumer" claim in the doc.

The doc states that lower_subject_predicate_ast's ImperativeFallback arm is the one consumer that reads this field. Two other functions in crates/engine/src/parser/oracle_effect/mod.rs also read it: sync_subject_into_nested_shuffle_sub and inject_subject_target. Both use subject.target ... .or(subject.affected) and now early-return on None.

The invariant the doc wants to state is narrower: ImperativeFallback is the only consumer that treats None as a coverage GAP; the other two treat None as "nothing to rebind". State that instead, so a future edit does not assume None is unreachable in those helpers.

📝 Proposed doc correction
-    /// unrepresentable: every consumer must say what it does with `None`, and
-    /// the one consumer that actually reads this field
-    /// (`lower_subject_predicate_ast`'s `ImperativeFallback` arm, the only
-    /// predicate kind that applies the subject filter) fails closed to
-    /// `Effect::unimplemented`. Same shape, same reason, as
+    /// unrepresentable: every consumer must say what it does with `None`. The
+    /// only consumer that applies this filter as a subject
+    /// (`lower_subject_predicate_ast`'s `ImperativeFallback` arm) fails closed
+    /// to `Effect::unimplemented`; the rebinding helpers
+    /// (`inject_subject_target`, `sync_subject_into_nested_shuffle_sub`) read it
+    /// only as a fallback after `target` and no-op on `None`. Same shape, same
+    /// reason, as
     /// [`EntersUnderSpec::UnboundAnaphor`].
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// unrepresentable: every consumer must say what it does with `None`, and
/// the one consumer that actually reads this field
/// (`lower_subject_predicate_ast`'s `ImperativeFallback` arm, the only
/// predicate kind that applies the subject filter) fails closed to
/// `Effect::unimplemented`. Same shape, same reason, as
/// [`EntersUnderSpec::UnboundAnaphor`].
/// unrepresentable: every consumer must say what it does with `None`. The
/// only consumer that applies this filter as a subject
/// (`lower_subject_predicate_ast`'s `ImperativeFallback` arm) fails closed
/// to `Effect::unimplemented`; the rebinding helpers
/// (`inject_subject_target`, `sync_subject_into_nested_shuffle_sub`) read it
/// only as a fallback after `target` and no-op on `None`. Same shape, same
/// reason, as
/// [`EntersUnderSpec::UnboundAnaphor`].
🤖 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 196 - 201, Update the
documentation near the subject field to remove the claim that ImperativeFallback
is the only consumer reading it. State instead that
lower_subject_predicate_ast’s ImperativeFallback arm is the only consumer
treating None as a coverage gap, while sync_subject_into_nested_shuffle_sub and
inject_subject_target treat None as nothing to rebind.

//!
//! CR 611.2c: one continuous effect naming several subjects determines the set
//! each part applies to independently — i.e. the UNION of the named subjects.
//! CR 301.5f: an Equipment attaches to a creature.

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

Replace the incorrect CR 301.5f citations.

CR 301.5f defines “equipped creature.” It does not define Equipment attachment. Line 24 and Line 79 should cite CR 301.5 and CR 301.5a for the attachment relation, or CR 301.5b for attachment by an ability. (media.wizards.com)

As per path instructions, rules-touching code must use a CR citation whose rule body describes the code.

Also applies to: 79-80

🤖 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/wand_of_orcus_compound_subject_6965.rs` at
line 24, Replace the incorrect CR 301.5f citations in the test comments near the
compound-subject attachment assertions with citations matching the implemented
attachment relation: use CR 301.5 and CR 301.5a, or CR 301.5b when the
attachment is caused by an ability. Update both cited locations while preserving
the test behavior.

Source: Path instructions

Comment on lines +99 to +126
// Reach-guard: the trigger really did fire and go on the stack. Without it
// the assertions below would pass vacuously on a card that never triggered.
assert_eq!(
runner.stack_names(),
vec!["Wand of Orcus".to_string()],
"the attack trigger must be on the stack, or nothing below is exercised"
);

runner.advance_until_stack_empty();
runner.state_mut().layers_dirty.mark_full();
evaluate_layers(runner.state_mut());

// The printed subject ("it and Zombies you control") carries an anaphor
// conjunct the union cannot bind, so the whole clause fails closed. Nothing
// is granted — most importantly, NOT everything.
for (id, label) in [
(host, "the equipped creature"),
(zombie, "a Zombie you control"),
(bear, "an unrelated creature you control"),
(land, "a LAND you control"),
] {
assert!(
!keywords(&runner, id).contains(&Keyword::Deathtouch),
"{label} must not gain deathtouch: the printed subject could not be \
bound, so the clause is an honest gap (issue #6965 — it used to \
become TargetFilter::Any and grant to every permanent)"
);
}

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

Assert the expected unbound-subject state before resolution.

stack_names() proves that the trigger was created. It does not prove that its execute chain is Effect::Unimplemented { name: "unbound_subject", .. }. A regression that drops the execute effect can produce no deathtouch and keep this test green. Inspect the trigger definition before resolution, assert the expected gap and diagnostic, then retain the runtime assertions.

As per path instructions, a negative assertion needs a reach guard that proves the tested path was reached.

🤖 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/wand_of_orcus_compound_subject_6965.rs`
around lines 99 - 126, Before calling advance_until_stack_empty in the Wand of
Orcus trigger test, inspect the triggered stack item's execute chain and assert
it contains Effect::Unimplemented with name "unbound_subject" plus the expected
diagnostic. Keep the existing stack_names reach guard and runtime no-Deathtouch
assertions, ensuring the negative assertions remain protected by proof that this
specific unbound-subject path was reached.

Source: Path instructions

Comment on lines +167 to +182
// CR 611.2c: both named subjects are covered.
assert!(
keywords(&runner, ally).contains(&Keyword::Hexproof),
"a creature you control is inside \"permanents you control\" and must \
gain hexproof"
);
assert!(
keywords(&runner, ally_land).contains(&Keyword::Hexproof),
"a LAND you control is a permanent you control and must gain hexproof"
);
// The negative arm is non-vacuous: the two positives above prove the grant
// resolved at all.
assert!(
!keywords(&runner, foe).contains(&Keyword::Hexproof),
"an opponent's permanent is excluded by \"you control\""
);

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

Test the player half of the compound subject.

Both asserted subjects are permanents controlled by P0. They only cover permanents you control. If you is dropped or player_scope binds the wrong player, this test still passes. Add a production-path player-targeting assertion that proves P1 cannot target P0 while Lazotep Plating is active. Hexproof has distinct player and permanent semantics. (media.wizards.com)

As per path instructions, integration tests must exercise the relevant runtime behavior.

🤖 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/wand_of_orcus_compound_subject_6965.rs`
around lines 167 - 182, Extend the integration test around runner and the
existing Lazotep Plating scenario with a production-path player-targeting
assertion. Have P1 attempt to target P0 while Plating is active and assert the
action is rejected because P0 has player hexproof. Keep the existing permanent
assertions and ensure the new check verifies the compound subject preserves the
“you” player binding.

Source: Path instructions

@matthewevans
matthewevans added this pull request to the merge queue Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Generated for head 7b50b70dfc65f9633074de6883c2a1286e476f54.

Parse changes introduced by this PR · 199 card(s), 121 signature(s) (baseline: main b654513cb391)

🟢 Added (3 signatures)

  • 151 cards · ➕ ability/unbound_subject · added: unbound_subject
    • Affected (first 3): Accident-Prone Apprentice, Acorn Catapult, Agent of Raffine (+148 more)
  • 8 cards · ➕ ability/unbound_subject · added: unbound_subject (conditional=previous effect outcome)
    • Affected (first 3): Construction Arsonist, Elfhame Sanctuary, Guildpact Greenwalker (+5 more)
  • 3 cards · ➕ ability/static_structure · added: static_structure
    • Affected (first 3): A-Earthquake Dragon, Cavern-Hoard Dragon, Shadow of Mortality

🔴 Removed (18 signatures)

  • 14 cards · ➖ ability/SpendManaAsAnyColor · removed: SpendManaAsAnyColor (affects=any target, target=controller)
    • Affected (first 3): Agent of Raffine, Bind to Secrecy, Clone Crafter (+11 more)
  • 11 cards · ➖ ability/can't · removed: can't
    • Affected (first 3): Archaeomancer's Spade, Clockwork Avian, Clockwork Beast (+8 more)
  • 11 cards · ➖ ability/have · removed: have
    • Affected (first 3): Better Offer, Cogwork Progenitor, Cyclopean Tomb (+8 more)
  • 10 cards · ➖ ability/become · removed: become
    • Affected (first 3): Argothian Uprooting, Gilded Drake, Glimmer Hoarder (+7 more)
  • 10 cards · ➖ ability/grant trigger · removed: grant trigger (affects=any target, duration=until end of turn, grants=grant trigger, target=any target)
    • Affected (first 3): Forgeborn Phoenix, Inspiring Easel, Jessie Zane, Fangbringer (+7 more)
  • 8 cards · ➖ ability/Draw · removed: Draw (target=any target)
    • Affected (first 3): Cait, Cage Brawler, Cleaver Blow, Karazikar, the Eye Tyrant (+5 more)
  • 5 cards · ➖ ability/CantBlock · removed: CantBlock (affects=any target, duration=until end of turn, grants=CantBlock, target=parent target)
    • Affected (first 3): Karlach, Tiefling Berserker, Karlach, Tiefling Guardian, Karlach, Tiefling Punisher (+2 more)
  • 5 cards · ➖ ability/CastFromZone · removed: CastFromZone (target=any target)
    • Affected (first 3): A-Earthquake Dragon, Cavern-Hoard Dragon, Mavinda, Students' Advocate (+2 more)
  • 5 cards · ➖ ability/GiveControl · removed: GiveControl (target=self, to=any target)
    • Affected (first 3): Ghazbán Ogre, Loxodon Peacekeeper, Thoughtbound Primoc (+2 more)
  • 4 cards · ➖ ability/CopySpell · removed: CopySpell (target=any target)
    • Affected (first 3): Glimmervoid Basin, Precursor Golem, Radiant Performer (+1 more)
  • 4 cards · ➖ ability/gain · removed: gain
    • Affected (first 3): Kemba's Outfitter, Riveteers Provocateur, Sibsig's Artisan (+1 more)
  • 4 cards · ➖ ability/grant ability · removed: grant ability (affects=any target, duration=until end of turn, grants=grant ability, target=any target)
    • Affected (first 3): Boareskyr Tollkeeper, Effluence Devourer, Gitrog, Horror of Zhava (+1 more)
  • 3 cards · ➖ ability/exile · removed: exile
    • Affected (first 3): Accident-Prone Apprentice, Albiorix, Goose Tyrant, Steady Tortoise
  • 3 cards · ➖ ability/grant trigger · removed: grant trigger (affects=any target, conditional=previous effect outcome, duration=until end of turn, grants=grant trigger, target=parent target)
    • Affected (first 3): Construction Arsonist, Lurking Spinecrawler, Scrutiny of the Guildpact
  • 2 cards · ➖ ability/DealDamage · removed: DealDamage (amount=4, target=creature or planeswalker)
    • Affected (first 3): Mephit's Enthusiasm, Molten Impact
  • 2 cards · ➖ ability/GainControlAll · removed: GainControlAll (duration=until end of turn, filter=creature)
    • Affected (first 3): Reins of Power, Twist Allegiance
  • 2 cards · ➖ ability/choose · removed: choose
    • Affected (first 3): Malik, Grim Manipulator, Trade Secrets
  • 2 cards · ➖ ability/deal · removed: deal
    • Affected (first 3): Boomflinger, Thud-for-Duds

🟡 Modified fields (4 signatures)

  • 4 cards · 🔄 ability/Pump · changed field target: any targetparent target
    • Affected (first 3): Euroakus, Fancy Footwork, Join Forces (+1 more)
  • 4 cards · 🔄 ability/SpendManaAsAnyColor · changed field affects: any targetcontroller
    • Affected (first 3): Arvinox, the Mind Flail, Discord, Lord of Disharmony, King Narfi's Betrayal (+1 more)
  • 2 cards · 🔄 ability/GiveControl · changed field to: any targetdefending player
    • Affected (first 3): Risky Move, Tahngarth, First Mate
  • 2 cards · 🔄 ability/grant Protection · changed field target: any target
    • Affected (first 3): Eon Frolicker, Faith's Shield
… 96 more signature(s) (103 card-changes) — showing first 96; see parse-diff.json

🟢 Added (16 signatures)

  • 2 cards · ➕ ability/unbound_subject · added: unbound_subject (duration=until end of combat)
  • 2 cards · ➕ ability/unbound_subject · added: unbound_subject (duration=until end of turn)
  • 2 cards · ➕ ability/unbound_subject · added: unbound_subject (kind=activated)
  • 1 card · ➕ ability/CantBeEnchanted, grant Flying · added: CantBeEnchanted, grant Flying (affects=last created, duration=permanent, grants=CantBeEnchanted, grants=grant Flying, target=last created)
  • 1 card · ➕ ability/Sacrifice · added: Sacrifice (target=creature)
  • 1 card · ➕ ability/TargetOnly · added: TargetOnly (target=creature)
  • 1 card · ➕ ability/grant Hexproof · added: grant Hexproof (affects=opponent or with Hexproof opponent controls creature, duration=until end of turn, grants=grant Hexproof, kind=activated)
  • 1 card · ➕ ability/set name Forest onto the battlefield tapped." They're still lands, base power 0… · added: set name Forest onto the battlefield tapped." They're still lands, base power 0, base toughness 0, grant Reach, grant Haste, add type creature, remove all Crea…
  • 1 card · ➕ ability/starting · added: starting
  • 1 card · ➕ ability/unbound_subject · added: unbound_subject (conditional=card changed zones this way)
  • 1 card · ➕ ability/unbound_subject · added: unbound_subject (conditional=not (target is permanent))
  • 1 card · ➕ ability/unbound_subject · added: unbound_subject (conditional=omen counters on self = 0)
  • 1 card · ➕ ability/unbound_subject · added: unbound_subject (conditional=revealed is creature)
  • 1 card · ➕ ability/unbound_subject · added: unbound_subject (conditional=when you do)
  • 1 card · ➕ ability/unbound_subject · added: unbound_subject (duration=for as long as condition)
  • 1 card · ➕ ability/unbound_subject · added: unbound_subject (duration=while on battlefield)

🔴 Removed (73 signatures)

  • 2 cards · ➖ ability/lose · removed: lose
  • 2 cards · ➖ ability/lose · removed: lose (duration=until end of combat)
  • 2 cards · ➖ ability/win · removed: win
  • 1 card · ➖ ability/CanAttackWithDefender · removed: CanAttackWithDefender (affects=any target, duration=until end of turn, grants=CanAttackWithDefender, target=any target)
  • 1 card · ➖ ability/CantBlock, DamageNotRemovedDuringCleanup · removed: CantBlock, DamageNotRemovedDuringCleanup (affects=any target, duration=until end of turn, grants=CantBlock, grants=DamageNotRemovedDuringCleanup, target=any ta…
  • 1 card · ➖ ability/CantBlock, grant trigger, grant Haste · removed: CantBlock, grant trigger, grant Haste (affects=any target, duration=until end of turn, grants=CantBlock, grants=grant Haste, grants=grant trigger, target=paren…
  • 1 card · ➖ ability/CastFromZone · removed: CastFromZone (conditional=life total (you) < starting life total, target=any target)
  • 1 card · ➖ ability/CastFromZone · removed: CastFromZone (duration=while on battlefield, target=tracked set #0)
  • 1 card · ➖ ability/CastFromZone · removed: CastFromZone (target=in command zone)
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (from=hand, target=in hand you control permanent, to=battlefield)
  • 1 card · ➖ ability/Choose · removed: Choose (choice=number (0-20))
  • 1 card · ➖ ability/Choose · removed: Choose (choice=one of: 1, 2, 3, kind=activated, persist=yes)
  • 1 card · ➖ ability/ChooseOneOf · removed: ChooseOneOf
  • 1 card · ➖ ability/DealDamage · removed: DealDamage (amount=6, target=triggering player)
  • 1 card · ➖ ability/DealDamage · removed: DealDamage (amount=X, conditional=previous effect outcome, target=opponent or planeswalker)
  • 1 card · ➖ ability/DealDamage · removed: DealDamage (amount=doom counters on self, target=any target)
  • 1 card · ➖ ability/DealDamage · removed: DealDamage (amount=filtered tracked set (card)+2, conditional=when you do, target=another)
  • 1 card · ➖ ability/DealDamage · removed: DealDamage (amount=intensity, target=any target)
  • 1 card · ➖ ability/Draw · removed: Draw (count=2)
  • 1 card · ➖ ability/ExtraTurn · removed: ExtraTurn (player=any target)
  • 1 card · ➖ ability/FlipCoin · removed: FlipCoin (flipper=Any)
  • 1 card · ➖ ability/GainLife · removed: GainLife (amount=event amount)
  • 1 card · ➖ ability/GrantCastingPermission · removed: GrantCastingPermission
  • 1 card · ➖ ability/LoseLife · removed: LoseLife (amount=divide(life total (scoped player), 2, rounded up))
  • 1 card · ➖ ability/MustAttack · removed: MustAttack (affects=any target, duration=until end of turn, grants=MustAttack, target=parent target)
  • 1 card · ➖ ability/PhaseOut · removed: PhaseOut (target=any target)
  • 1 card · ➖ ability/Pump · removed: Pump (conditional=previous effect outcome, p/t=+4/+4, target=parent target)
  • 1 card · ➖ ability/Pump · removed: Pump (p/t=+# of colors among you control permanent/+0, target=any target)
  • 1 card · ➖ ability/Pump · removed: Pump (p/t=+1/+0, target=any target)
  • 1 card · ➖ ability/Pump · removed: Pump (p/t=+2/+2, target=parent target)
  • 1 card · ➖ ability/Pump · removed: Pump (p/t=+amount from preceding effect/+amount from preceding effect, target=any target)
  • 1 card · ➖ ability/Pump · removed: Pump (p/t=-2/+0, target=any target)
  • 1 card · ➖ ability/PutCounter · removed: PutCounter (counter=1 counter, target=permanent)
  • 1 card · ➖ ability/RaiseCost · removed: RaiseCost (affects=any target, duration=until end of turn, grants=RaiseCost, target=any target)
  • 1 card · ➖ ability/ReduceCost · removed: ReduceCost (affects=any target, duration=until end of turn, grants=ReduceCost, target=any target)
  • 1 card · ➖ ability/ReduceCost, grant trigger, grant Haste · removed: ReduceCost, grant trigger, grant Haste (affects=any target, duration=until end of turn, grants=ReduceCost, grants=grant Haste, grants=grant trigger, target=any…
  • 1 card · ➖ ability/Reveal · removed: Reveal (conditional=card changed zones this way, target=parent target)
  • 1 card · ➖ ability/RevealTop · removed: RevealTop (count=1, player=any target)
  • 1 card · ➖ ability/Sacrifice · removed: Sacrifice (target=parent target slot 0)
  • 1 card · ➖ ability/Sacrifice · removed: Sacrifice (target=self)
  • 1 card · ➖ ability/Scry · removed: Scry (count=2)
  • 1 card · ➖ ability/Token · removed: Token (token=+1/+1 Green Squirrel (Creature Squirrel))
  • 1 card · ➖ ability/Token · removed: Token (token=Clue (Artifact Clue))
  • 1 card · ➖ ability/UntapAll · removed: UntapAll (filter=tapped scoped player controls artifact or tapped scoped player controls creature or tapped scoped player controls land)
  • 1 card · ➖ ability/WinTheGame · removed: WinTheGame (conditional=omen counters on self = 0)
  • 1 card · ➖ ability/become · removed: become (conditional=not (target is permanent))
  • 1 card · ➖ ability/become · removed: become (conditional=previous effect outcome)
  • 1 card · ➖ ability/become · removed: become (kind=activated)
  • 1 card · ➖ ability/can · removed: can (duration=for as long as condition)
  • 1 card · ➖ ability/can · removed: can (duration=until end of turn, kind=activated)
  • 1 card · ➖ ability/can't · removed: can't (duration=until end of turn)
  • 1 card · ➖ ability/discard · removed: discard
  • 1 card · ➖ ability/draw · removed: draw
  • 1 card · ➖ ability/draw · removed: draw (conditional=previous effect outcome, duration=until end of turn)
  • 1 card · ➖ ability/exile · removed: exile (duration=until end of turn)
  • 1 card · ➖ ability/flip · removed: flip
  • 1 card · ➖ ability/grant Casualty · removed: grant Casualty (affects=any target, duration=until end of turn, grants=grant Casualty, target=any target)
  • 1 card · ➖ ability/grant Deathtouch · removed: grant Deathtouch (affects=any target, duration=until end of turn, grants=grant Deathtouch, target=any target)
  • 1 card · ➖ ability/grant DoubleTeam · removed: grant DoubleTeam (affects=any target, duration=until end of turn, grants=grant DoubleTeam, target=any target)
  • 1 card · ➖ ability/grant Flash · removed: grant Flash (affects=any target, duration=until end of turn, grants=grant Flash, target=any target)
  • 1 card · ➖ ability/grant Offspring · removed: grant Offspring (affects=any target, duration=until end of turn, grants=grant Offspring, target=any target)
  • 1 card · ➖ ability/grant Shroud · removed: grant Shroud (affects=any target, duration=until end of turn, grants=grant Shroud, target=any target)
  • 1 card · ➖ ability/grant Storm · removed: grant Storm (affects=any target, duration=until end of turn, grants=grant Storm, target=any target)
  • 1 card · ➖ ability/grant ability · removed: grant ability (affects=any target, conditional=previous effect outcome, duration=until end of turn, grants=grant ability, target=parent target)
  • 1 card · ➖ ability/grant ability, grant trigger · removed: grant ability, grant trigger (affects=any target, duration=until end of turn, grants=grant ability, grants=grant trigger, target=any target)
  • 1 card · ➖ ability/grant static ability · removed: grant static ability (affects=any target, conditional=target is creature, duration=until end of turn, grants=grant static ability, target=parent target)
  • 1 card · ➖ ability/grant trigger · removed: grant trigger (affects=any target, duration=until end of turn, grants=grant trigger, target=parent target)
  • 1 card · ➖ ability/phase · removed: phase (duration=until end of turn)
  • 1 card · ➖ ability/power +1, toughness +1, ReduceCost · removed: power +1, toughness +1, ReduceCost (affects=any target, duration=until end of turn, grants=ReduceCost, grants=power +1, grants=toughness +1, target=any target)
  • 1 card · ➖ ability/power +1, toughness +1, grant Vigilance · removed: power +1, toughness +1, grant Vigilance (affects=any target, duration=until end of turn, grants=grant Vigilance, grants=power +1, grants=toughness +1, target=a…
  • 1 card · ➖ ability/power +2, toughness +0, grant Haste · removed: power +2, toughness +0, grant Haste (affects=any target, duration=until end of turn, grants=grant Haste, grants=power +2, grants=toughness +0, target=any targe…
  • 1 card · ➖ ability/put · removed: put (duration=until end of turn)
  • 1 card · ➖ ability/reveal · removed: reveal

🟡 Modified fields (7 signatures)

  • 2 cards · 🔄 ability/grant static ability · changed field affects: any targetcontroller
  • 1 card · 🔄 ability/ChangeZone · changed field target: scoped player controls in hand artifact or scoped player controls in hand creature or scoped player controls in hand en…parent target's controller controls in hand parent target's controller controls artifact or parent target's controller …
  • 1 card · 🔄 ability/Discard · changed field target: any targetdefending player
  • 1 card · 🔄 ability/GivePlayerCounter · changed field target: any targetdefending player
  • 1 card · 🔄 ability/MayLookAtFaceDown · changed field target: any targetcontroller
  • 1 card · 🔄 ability/grant Protection · changed field affects: any targetcontroller or you control permanent
  • 1 card · 🔄 ability/grant Protection · changed field affects: any targetcontroller or you control planeswalker

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

Merged via the queue into main with commit 644c713 Aug 4, 2026
15 checks passed
@matthewevans
matthewevans deleted the ship/6965-compound-subject-fail-closed branch August 4, 2026 23:19
@matthewevans

Copy link
Copy Markdown
Member Author

All eight review findings are addressed in #7009, opened because this PR merged out of the queue before the review landed.

Six accepted:

  • `bind_recipient_without_recipient_slot` overwriting an existing `player_scope` — confirmed unguarded at the assignment while the doc promised a fail-closed contract. Both halves clone one body, so it would have clobbered a printed fan-out differently on each half. Now refuses; the caller already turns `false` into `Unimplemented`.
  • `CR 301.5f` → `CR 301.5a` at both Wand of Orcus sites. Verified against `docs/MagicCompRules.txt`: 301.5f is about what an ability means by "equipped creature"; 301.5a is the attachment relation.
  • `CR 109.4` dropped from the Infernal Offering test — that conjunct is a player chosen while applying the effect (CR 608.2d, already cited), with no object controller in play.
  • `CR 109.4` → `CR 115.1` on the targeted-player fail-closed contract.
  • Both vacuous negative assertions now pin `name == "unbound_subject"` and quote the causing conjunct, plus a positive reach guard on the parser test and a pre-resolution chain assertion on the Wand test.
  • The `SubjectPhraseAst::affected` "one consumer" doc — confirmed sync_subject_into_nested_shuffle_sub and inject_subject_target both read it via `target.or(affected)`. Narrowed to the intended invariant.

Two declined, with reasons:

  • CR 608.2c on the Life at Stake test. Kept. The test asserts the chain order mirrors printed order ("you" first, then the target's controller), which is precisely what 608.2c governs. The finding asked to remove it "unless the test documents resolution in written order" — it does.
  • CR 109.4 on the same test's `ParentObjectTargetController` binding. Kept. Unlike the Infernal Offering case, the player here genuinely is named through a battlefield object's controller, which is what 109.4 establishes.

One note on process, since it validated the reach-guard finding: my first attempt at the positive reach guard used a bare "You and that player each sacrifice a creature." and went red — "that player" without its preceding "Choose an opponent." is itself an unbound subject. The guard carries the full Infernal Offering text now. Exactly the kind of thing a reach guard is supposed to surface.

lgray pushed a commit to lgray/phase that referenced this pull request Aug 5, 2026
…e fail-closed tests (phase-rs#6965) (phase-rs#7009)

Six findings from the CodeRabbit review of phase-rs#7003, which merged before these
landed.

**Correctness.** `bind_recipient_without_recipient_slot` assigned
`def.player_scope = Some(scope)` unconditionally while documenting a
"Total and FAIL-CLOSED" contract. That contract only covered recipients no
`PlayerFilter` can name — not a body that already carries its own iteration
scope. Both halves are rewritten from clones of ONE parsed body, so an
unguarded stamp would replace a printed per-player fan-out with a single
recipient, and with a different one on each half. Refuse when a scope is
already present; the caller already turns `false` into `Unimplemented`.

**CR citations.** CR 301.5f defines what an ability means by "equipped
creature"; it does not define attachment, which is CR 301.5a. Both sites now
cite the rule whose body describes the code. CR 109.4 is about which objects
have controllers, so it does not describe Infernal Offering's "that player" —
a player chosen while applying the effect, CR 608.2d, which the doc already
cited. Dropped it there. The targeted-player fail-closed contract now cites
CR 115.1, which is the rule that actually makes those conjuncts targets.

**Vacuous negative assertions.** Two tests accepted any `Effect::Unimplemented`
and so would have passed on a clause that died earlier, or on a dropped effect.
Both now assert the gap is named `unbound_subject` and quotes the conjunct that
caused it. `recipient_less_body_with_a_targeted_player_conjunct_fails_closed`
gains a positive reach guard, since a dead distributor fails closed on
everything including what it should bind — the guard carries the full Infernal
Offering text, because "that player" without its preceding "Choose an opponent."
is itself an unbound subject. The Wand of Orcus test proved only that a trigger
reached the stack, which a dropped execute effect would also satisfy while
granting no deathtouch; it now inspects the trigger's chain before resolution.

**Docs.** `SubjectPhraseAst::affected` claimed one consumer reads it.
`sync_subject_into_nested_shuffle_sub` and `inject_subject_target` read it too,
via `target.or(affected)`. Narrowed to the invariant actually intended:
`ImperativeFallback` is the only consumer treating `None` as a coverage gap;
the others treat it as "nothing to rebind". `None` is reachable in all three.

Two citations kept deliberately. CR 608.2c stays on the Life at Stake test: it
asserts chain order mirroring printed order, which is what that rule governs.
CR 109.4 stays on the same test's `ParentObjectTargetController` binding, where
the player IS named through a battlefield object's controller.

Verified: parser tests 1583 passed; engine lib 18487 passed; integration 4487
passed; clippy -D warnings clean. The new reach guard was watched go red first
(it caught a wrong positive example, which is how the antecedent requirement
above was found).

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parser fails OPEN: an unparseable subject becomes TargetFilter::Any, silently making the effect board-wide

1 participant