fix(engine): don't affect a blinked referent from a delayed trigger (CR 400.7) - #7099
Conversation
…CR 400.7) Goryo's Vengeance followed by Ephemerate on the reanimated creature still exiled it at the beginning of the next end step. A delayed triggered ability snapshots its `ParentTarget` referent as a bare `ObjectId` and re-resolved it at firing with no incarnation comparison, so a creature that left and returned was still affected even though CR 400.7 makes it a new object. CR 603.7c: "if that object is no longer in the zone it's expected to be in at the time the delayed triggered ability resolves, the ability won't affect it. (Note that if that object left that zone and then returned, it's a new object and thus won't be affected. See rule 400.7.)" The trigger still triggers and still goes on the stack (CR 603.7b) — the trigger event occurred. Only its effect on a stale referent changes. Pin each snapshotted object referent to its `ObjectIncarnationRef` at delayed-trigger creation (`ResolvedAbility.target_incarnations`), and filter stale elements at read time via the id-keyed `target_pin_is_current`. Reuses the shipped `ObjectIncarnationRef` primitive rather than introducing a new one. The predicate is scoped by expected zone, derived as a total function of `DelayedTriggerCondition` via an exhaustive match with no wildcard arm: a condition that names the referent's own zone change (either direction) must not pin, or "when it dies, return that card" cards would go permanently inert. That verdict is computed from the parser-emitted condition before `bind_tracked_set_to_condition` and `bind_contextual_filter_to_condition` rewrite the anaphor away — a post-bind read is vacuously false. Guards are applied at both `targeting.rs` chokepoints plus each handler that reads `ability.targets` directly (sacrifice, destroy, copy_spell, counters, gain_control, attach, remove_from_combat, and the Tier C set). Stale elements are dropped, never the whole list: an emptied list re-binds `ParentTarget` to `ability.source_id`, which would make Goryo's exile itself from the graveyard. Every early return emits `EffectResolved` so the no-op stays observable. Also fixes a second violation of the same card's ruling: a creature that died before the end step was exiled out of the graveyard. It now stays put. Not covered: 33 tracked-set cards (Eerie Interlude, Ghostway, Yorion, Venser, Touch the Spirit Realm) lose the anaphor to `bind_tracked_set_to_ability_chain` before the gate sees them. Filed as follow-up. The event-context authority is deliberately unguarded per CR 400.7e — it re-derives the referent from the firing event rather than reading the snapshot, so there is nothing stale to invalidate. Tests: `crates/engine/tests/integration/delayed_parent_target_incarnation.rs` plus inline predicate units. The blink case and the died-before-end-step case were both watched go red before the fix and green after; `saffi eriksdotter` and `lagrella` are regression controls green in both runs; `whippoorwill` is the placement detector that goes red when the pre-bind read is moved.
Review follow-ups on the CR 400.7 delayed-trigger referent pin. `remove_from_combat.rs`'s all-stale guard sat below the `SelfRef` match arm, whose subject is `ability.source_id` and never the snapshot referent. The guard reads `ability.targets`, so a stale pin on an unrelated object could cancel a self-removal — predicate decoupled from the subject it suppresses. Unreachable today (the in-class population is `melee`, whose filter is a bare `ParentTarget`, so no `SelfRef` node co-occurs with a pin), but it is the same collapse of "no target declared" into "declared referent went stale" that `flip_permanent.rs` and `transform_effect.rs` preserve their raw `as_slice()` match to avoid. Now scoped, keeping the guard above the `targets.is_empty() => vec![ability.source_id]` rebind, which is load-bearing. `sacrifice.rs`: the guard comment cited CR 701.17a, which is mill; sacrifice is CR 701.21a. Corrects only the line this change added — the file carries 13 pre-existing occurrences of the same mis-citation, left for one auditable pass rather than migrated piecemeal here. `ability.rs`: `clear_trigger_identity_recursive` documented the new `target_incarnations.clear()` against CR 104.4b loop detection only. It is also the CR 603.3b auto-ordering identity stripper, and deliberately the opposite of `inert_trigger_abilities_eq_ignoring_provenance`, which compares `target_incarnations`. Same field, different questions; both callers operate on clones so no production pin is cleared. Documented so the two sites do not read as an accidental disagreement. Engine suite: 23,249 passed / 0 failed.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughDelayed abilities now capture object-incarnation pins for parent targets. Resolution filters stale referents, preserves positional target slots, and completes fully stale effects as no-ops. New integration tests cover delayed trigger and zone-change cases. ChangesDelayed target incarnation handling
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant DelayedTrigger
participant Ability
participant GameState
participant EffectResolver
participant ResolutionEvent
DelayedTrigger->>Ability: Capture targets and incarnation pins
Ability->>GameState: Validate pinned object references
GameState-->>Ability: Return live targets
Ability->>EffectResolver: Resolve filtered targets
alt All pinned targets are stale
EffectResolver->>ResolutionEvent: Emit EffectResolved no-op
else Live targets remain
EffectResolver->>ResolutionEvent: Apply effect to live targets
end
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/engine/src/game/effects/gain_control.rs (1)
200-225: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winApply the
ParentTargetSlotcarve-out here too; the pre-arm does not always return.The comment at Line 221 states that
ParentTargetSlot"is handled above byresolve_parent_slot_from_rootand never reaches this read", and that amatches!guard "would be dead code". The pre-arm at Line 200 does not guarantee that.
resolve_parent_slot_from_rootreturns anOption<TargetRef>. The early return at Line 204 fires only forSome(TargetRef::Object(id)). ForNone, and forSome(TargetRef::Player(_)), control falls through to Line 224. The slot filter is then still active, andeffect_object_targetsreceives the filteredlive_targetslist.
effect_object_targetsindexesParentTargetSlotpositionally. Dropping a stale element renumbers every later slot. Withtargets = [stale_a, live_b]andindex: 1, the filtered list is[live_b], so slot 1 is out of range. Per the comment at Line 199, an out-of-range index falls through to "all inherited targets". The effect then takes control of the wrong permanent.The sibling
give_control_object_targetsat Line 340 applies exactly this carve-out.resolve_givehas noParentTargetSlotpre-arm, and the code correctly compensates. Here the pre-arm exists but is partial, so the carve-out is still required.Control-change effects default to
Duration::Permanentat Line 23, so a mis-bound target is not self-correcting.🐛 Proposed fix: mirror the give-control carve-out
- // Slot carve-out does NOT apply here: `ParentTargetSlot` is handled above by - // `resolve_parent_slot_from_root` and never reaches this read. Adding a - // `matches!` guard would be dead code. - let live_targets = ability.live_object_targets(state); - let chosen_objects = super::effect_object_targets(filter, &live_targets); + // Slot carve-out DOES apply: the `ParentTargetSlot` pre-arm above returns + // only for `Some(TargetRef::Object(_))`. A `None` or player-valued slot + // resolution falls through to here with the slot filter intact, and + // `effect_object_targets` indexes it positionally. Pass the raw list for + // that shape so a dropped stale element cannot renumber the slots. + let live_targets = ability.live_object_targets(state); + let pool: &[TargetRef] = if matches!(filter, TargetFilter::ParentTargetSlot { .. }) { + &ability.targets + } else { + &live_targets + }; + let chosen_objects = super::effect_object_targets(filter, pool);🤖 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/game/effects/gain_control.rs` around lines 200 - 225, Update the target preparation in the gain-control effect around effect_object_targets and chosen_objects to preserve the ParentTargetSlot carve-out even when resolve_parent_slot_from_root returns None or a Player. Exclude ParentTargetSlot from the pre-arm substitution path, mirroring give_control_object_targets, so positional slot indices are resolved against the unfiltered live object targets and existing non-slot behavior remains unchanged.
🧹 Nitpick comments (3)
crates/engine/src/game/effects/transform_effect.rs (1)
39-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThe empty-pin case is covered here, but not on the sibling call site.
transform_effect_uses_source_when_no_explicit_targetat line 230 constructs an ability with emptytargetsand emptytarget_incarnations, then asserts the source transforms. That test passes through this guard and would fail ifpinned_object_targets_all_stalereturnedtruefor an empty pin list. This file therefore pins the helper's empty-list semantics.The same helper is called in
crates/engine/src/game/effects/change_zone.rsat lines 558-565 on the untargeted zone-scan path, which has no equivalent discriminating test. Add one there so the contract is guarded at both call sites.The placement reasoning above is correct: the
[]arm at line 67 resolves toability.source_id, so substituting inside theas_slice()match would rebind instead of no-op.🤖 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/game/effects/transform_effect.rs` around lines 39 - 62, Add a regression test in the change-zone untargeted zone-scan path near the call to pinned_object_targets_all_stale, constructing an ability with empty targets and empty target_incarnations and asserting the source is transformed or otherwise retains the existing no-explicit-target behavior. Ensure the test confirms an empty pin list returns false and does not trigger the stale-target early return, while preserving the helper placement and raw ability.targets matching logic.Source: Path instructions
crates/engine/src/game/effects/remove_from_combat.rs (1)
19-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCarry the
SelfRefdiscrimination out of the first match instead of re-deriving it.The match at Line 19 already separates the
SelfRefshape from the otherRemoveFromCombatshape. Line 69 re-matches&ability.effectto recover the same bit.Two independent matches on the same value can drift. If a third
RemoveFromCombatshape is added, the compiler forces an update at Line 19 but not at Line 69, and the guard would silently apply to the new shape.Bind the discrimination once.
♻️ Proposed refactor
- let targets: Vec<_> = match &ability.effect { + let (targets, subject_is_self_ref): (Vec<_>, bool) = match &ability.effect { Effect::RemoveFromCombat { target: TargetFilter::SelfRef, } => { - vec![ability.source_id] + (vec![ability.source_id], true) } Effect::RemoveFromCombat { target } => { let live_targets = ability.live_object_targets(state); let pool: &[TargetRef] = if matches!(target, TargetFilter::ParentTargetSlot { .. }) { &ability.targets } else { &live_targets }; - super::effect_object_targets(target, pool) + (super::effect_object_targets(target, pool), false) } _ => return Ok(()), };Then delete the re-derivation:
- let subject_is_self_ref = matches!( - &ability.effect, - Effect::RemoveFromCombat { - target: TargetFilter::SelfRef - } - ); if !subject_is_self_ref && ability.pinned_object_targets_all_stale(state) {Also applies to: 69-75
🤖 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/game/effects/remove_from_combat.rs` around lines 19 - 45, Bind whether the effect uses TargetFilter::SelfRef while handling ability.effect in the existing targets match, then reuse that value at the later guard instead of re-matching ability.effect. Remove the duplicate discrimination so any future RemoveFromCombat shape is covered by the compiler-enforced match update.crates/engine/src/game/effects/cast_from_zone.rs (1)
405-418: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace the review-process narrative in the new comments with durable rules documentation.
The new guards across this cohort carry comment blocks that record the reasoning of the change process rather than the behavior of the code. Examples in the current text: "The plan pre-flagged this file as a possible STOP", "the Tier C census counts as distinct effect types", "No early return needed, re-verified at
resolve_giverather than copied", "Population is 0 today".Three concrete costs:
- The statements are unverifiable at read time. A future reader cannot check "Tier C" or "the plan" against anything in the repository.
- The population claims go stale silently. "Population is 0 today" and "Unreachable today" become wrong the moment a card is added, and nothing fails.
- One narrative asserts a false invariant. The
gain_control.rsblock states a slot guard "would be dead code". It is reachable. That is raised separately atcrates/engine/src/game/effects/gain_control.rsLine 200-225.CLAUDE.md requires rules-touching code to carry a verified
CR <number>: <description>annotation. Keep the CR citation and the one-sentence rule statement. Move the placement rationale to a single short sentence. Drop the census and plan references.Per site:
crates/engine/src/game/effects/cast_from_zone.rs#L405-L418: remove the paragraph about the plan, the threescoped_ability.targetsassignments, and:257. Keep the CR 400.7 + CR 603.7c sentence and one sentence naming the fallback pools the guard protects.crates/engine/src/game/effects/discard.rs#L207-L220: remove the "Tier C census" clause. Keep the CR citation and the reasonEffectKind::from(&ability.effect)is used instead of a literal.crates/engine/src/game/effects/flip_permanent.rs#L27-L42: keep the CR citation and the sentence explaining that[]means "no target declared" while an all-stale pin means "declared referent is gone". Drop the rest.crates/engine/src/game/effects/gain_control.rs#L208-L223: replace the "dead code" claim with the corrected carve-out from the separate comment on that file.crates/engine/src/game/effects/phase_out.rs#L97-L110: keep the CR citation and the sentence on why the guard sits after the player branch. Drop "ALTITUDE IS LOAD-BEARING".crates/engine/src/game/effects/remove_from_combat.rs#L47-L68: keep the CR citation and the sentence scoping the guard to non-SelfRef. Drop the population claim and the cross-file mirroring notes.crates/engine/src/game/effects/tap_untap.rs#L57-L71: keep the CR citation and the sentence stating there is no source fallback. Drop "verified rather than assumed".As per path instructions: "
CLAUDE.mdat the repo root is the authoritative design document", and it requires that "Delayed-trigger and object-incarnation behavior must be annotated with verified CR citations and descriptions".Source: Path instructions
🤖 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/game/ability_rw.rs`:
- Line 3729: Update the target_incarnations comment to remove the unsupported CR
603.7c reference and retain only the supported CR 400.7 citation and its
object-pin explanation; do not change the target_incarnations behavior.
In `@crates/engine/src/game/effects/flip_permanent.rs`:
- Around line 43-50: Add a production-pipeline test for the stale pinned-target
guard in the delayed flip ability flow, using a non-SelfRef ParentTarget with
populated target_incarnations; bump the referent’s incarnation before
resolution, then assert the referent remains unflipped and an EffectResolved
event is emitted. Do not reuse self_flip_does_not_follow_a_blinked_source, since
it only covers stale_self_flip.
In `@crates/engine/src/types/ability.rs`:
- Around line 23828-23850: The current target filtering can preserve slot
indexes incorrectly by allowing stale object IDs to resolve to re-entered
objects. In crates/engine/src/types/ability.rs:23828-23850, add a reusable
slot-aware lookup that indexes self.targets directly and rejects a stale object
at the requested slot. In crates/engine/src/game/effects/effect.rs:760-772,
route ParentTargetSlot through it without binding transient effects from raw
targets; in counters.rs:1848-1853, attach.rs:252-273 and 560-567, and
sacrifice.rs:232-240, validate each selected slot/object before use, retaining
declared positional indexes and rejecting stale incarnations.
In `@crates/engine/tests/integration/delayed_parent_target_incarnation.rs`:
- Around line 134-162: Update advance_past_end_of_turn to panic on an error from
runner.act, matching advance_until_delayed_triggers_resolve instead of returning
early. After the loop, assert that runner.state().turn_number is greater than
start_turn so callers such as T-D1 arm 2 cannot pass without advancing the turn.
- Around line 408-424: Update the T-Z1 reach guard around the resolved removal
to reject Zone::Battlefield, matching T-D1’s assert_ne! pattern so the victim
must have left the battlefield. Add a positive assertion after
advance_until_delayed_triggers_resolve confirming the victim returned to
Zone::Battlefield, while preserving the existing delayed-trigger outcome
assertion and diagnostics.
---
Outside diff comments:
In `@crates/engine/src/game/effects/gain_control.rs`:
- Around line 200-225: Update the target preparation in the gain-control effect
around effect_object_targets and chosen_objects to preserve the ParentTargetSlot
carve-out even when resolve_parent_slot_from_root returns None or a Player.
Exclude ParentTargetSlot from the pre-arm substitution path, mirroring
give_control_object_targets, so positional slot indices are resolved against the
unfiltered live object targets and existing non-slot behavior remains unchanged.
---
Nitpick comments:
In `@crates/engine/src/game/effects/remove_from_combat.rs`:
- Around line 19-45: Bind whether the effect uses TargetFilter::SelfRef while
handling ability.effect in the existing targets match, then reuse that value at
the later guard instead of re-matching ability.effect. Remove the duplicate
discrimination so any future RemoveFromCombat shape is covered by the
compiler-enforced match update.
In `@crates/engine/src/game/effects/transform_effect.rs`:
- Around line 39-62: Add a regression test in the change-zone untargeted
zone-scan path near the call to pinned_object_targets_all_stale, constructing an
ability with empty targets and empty target_incarnations and asserting the
source is transformed or otherwise retains the existing no-explicit-target
behavior. Ensure the test confirms an empty pin list returns false and does not
trigger the stale-target early return, while preserving the helper placement and
raw ability.targets matching logic.
🪄 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: 7a351d31-02b6-4266-9519-b7c55559f2ef
📒 Files selected for processing (38)
crates/engine/src/game/ability_rw.rscrates/engine/src/game/ability_scan.rscrates/engine/src/game/effects/additional_phase.rscrates/engine/src/game/effects/attach.rscrates/engine/src/game/effects/cast_from_zone.rscrates/engine/src/game/effects/change_zone.rscrates/engine/src/game/effects/copy_spell.rscrates/engine/src/game/effects/counters.rscrates/engine/src/game/effects/deal_damage.rscrates/engine/src/game/effects/delayed_trigger.rscrates/engine/src/game/effects/destroy.rscrates/engine/src/game/effects/discard.rscrates/engine/src/game/effects/double.rscrates/engine/src/game/effects/effect.rscrates/engine/src/game/effects/extra_turn.rscrates/engine/src/game/effects/flip_permanent.rscrates/engine/src/game/effects/force_block.rscrates/engine/src/game/effects/gain_control.rscrates/engine/src/game/effects/grant_extra_loyalty_activations.rscrates/engine/src/game/effects/phase_out.rscrates/engine/src/game/effects/player_counter.rscrates/engine/src/game/effects/remove_from_combat.rscrates/engine/src/game/effects/reverse_turn_order.rscrates/engine/src/game/effects/sacrifice.rscrates/engine/src/game/effects/skip_next_step.rscrates/engine/src/game/effects/skip_next_turn.rscrates/engine/src/game/effects/tap_untap.rscrates/engine/src/game/effects/transform_effect.rscrates/engine/src/game/effects/vote.rscrates/engine/src/game/layers.rscrates/engine/src/game/resolution_prompt.rscrates/engine/src/game/stack.rscrates/engine/src/game/targeting.rscrates/engine/src/types/ability.rscrates/engine/src/types/identifiers.rscrates/engine/tests/integration/delayed_parent_target_incarnation.rscrates/engine/tests/integration/main.rscrates/engine/tests/integration/the_chain_veil_loyalty_grants.rs
Both found by CodeRabbit on #7099, both real. `advance_past_end_of_turn` returned silently when `runner.act` errored, so no game time passed and every downstream "the referent survived to end of turn" assertion held trivially. It now panics with the phase and `waiting_for`, and asserts the turn actually advanced. T-Z1's reach-guard accepted `Zone::Battlefield`, which is also the state where the removal never resolved and the victim never died — and the test's conclusion is also `Battlefield`, so both held while proving nothing about Saffi's delayed trigger. Tightening it to `assert_ne!(Battlefield)` made the test FAIL, which confirmed the control had been vacuous. The final zone cannot serve as the guard here: Saffi's trigger fires on the death and returns the card within the same resolution, so the victim legitimately ends on the battlefield — indistinguishable from never having left. The guard now asserts the `ZoneChanged { to: Graveyard }` event for the victim, proving the death happened. This matters because T-Z1 is the regression control the CR 400.7 predicate design rests on: it is the evidence that "when it dies, return that card" cards still work. It now carries that weight. 8/8 green in the delayed-trigger file.
|
Thanks — two of these were real and are now fixed, and one of them was a genuinely valuable catch. Fixed
T-Z1's reach-guard admitted the "never died" case — correct, and this one mattered. Tightening it to The fix isn't the one suggested, though, and the reason is worth recording: the final zone can't serve as the guard here at all. Saffi's trigger fires on the death and returns the card within the same resolution, so the victim legitimately ends on the battlefield — indistinguishable from never having left. The guard now asserts the This matters beyond the one test: T-Z1 is the regression control the whole CR 400.7 predicate design rests on — it is the evidence that "when it dies, return that card" cards (Saffi, Adarkar Valkyrie, Cryptek, Together Forever) still work under the pin. It now actually carries that weight. Not changing — with evidence
The "unverified" verdict came from
Measured population: zero. The affirmative slot pin-checks were deliberately cut for that reason, per this repo's "don't design for hypothetical future requirements" rule, and filed as a follow-up. What was kept is the slot renumbering carve-out on the substitutions this change introduces — that one isn't speculative, it's a correctness constraint on the edit itself, and it has a unit test. Acknowledged, tracked
|
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
Fixes a user-reported Modern Goryo's Vengeance interaction: casting Goryo's Vengeance and then Ephemerate on the reanimated creature still exiled it at the beginning of the next end step.
Per CR 603.7c, a delayed triggered ability that refers to a particular object won't affect it if the object left its zone and returned — it's a new object (CR 400.7). The trigger itself still triggers and still goes on the stack (CR 603.7b), and this change preserves that; only its effect on a stale referent changes.
Also fixes a second violation of the same card's official ruling, found while testing: a creature that died before the end step was being exiled out of the graveyard. The ruling says it "will remain in its current zone. It won't be exiled."
Approach
A delayed trigger snapshotted its
ParentTargetreferent as a bareObjectIdand re-resolved it at firing with no incarnation comparison. Each snapshotted object referent is now pinned to itsObjectIncarnationRefat creation (ResolvedAbility.target_incarnations) and stale elements are dropped at read time via the id-keyedtarget_pin_is_current. This reuses the shippedObjectIncarnationRefprimitive rather than introducing a new one.Two constraints are load-bearing:
ParentTargettoability.source_id, which would make Goryo's exile itself from the graveyard.bind_tracked_set_to_conditionandbind_contextual_filter_to_conditionrewrite the anaphor away. A post-bind read is vacuously false for every pair.The predicate is scoped by expected zone, derived as a total function of
DelayedTriggerConditionvia an exhaustive match with no wildcard arm — a condition naming the referent's own zone change (either direction) must not pin, or "when it dies, return that card" cards (Saffi Eriksdotter, Adarkar Valkyrie, Cryptek, Together Forever) would go permanently inert.Guards are applied at both
targeting.rschokepoints plus every handler that readsability.targetsdirectly:sacrifice,destroy,copy_spell,counters,gain_control,attach,remove_from_combat, and the Tier C set. Every early return emitsEffectResolvedso the no-op stays observable.Evidence
Both bugs were watched go red before the fix and green after, through the real cast pipeline:
left: Exile)left: Exile)saffi eriksdotterlagrellawhippoorwillKnown limitations (disclosed, not hidden)
bind_tracked_set_to_ability_chainbefore the gate sees them. Runtime coverage is 267/317 pairs (84.2%), not the ~95% a pre-bind count suggests. Filed as follow-up.cast_from_zone.rsis the highest-risk edit: large function, one in-class card (power pack), no test.ParentTargetinstead ofSelfRef) and aCR 701.17a→CR 701.21amis-citation (701.17a is mill; sacrifice is 701.21).Process
9 plan-review rounds (round 7 returned no blocking gaps), 2 implementation rounds, 1 implementation review — verdict "the implementation is sound", nothing blocking. Its three findings are addressed in the second commit.
Summary by CodeRabbit