Skip to content

fix(engine): make the CR 603.4 hoist binding classifiers fail closed - #7491

Open
JacobWoodson wants to merge 3 commits into
phase-rs:mainfrom
JacobWoodson:claude/github-issue-7406-1ba6de
Open

fix(engine): make the CR 603.4 hoist binding classifiers fail closed#7491
JacobWoodson wants to merge 3 commits into
phase-rs:mainfrom
JacobWoodson:claude/github-issue-7406-1ba6de

Conversation

@JacobWoodson

Copy link
Copy Markdown
Contributor

Fixes #7406.

The defect

filter_binding_diverges and gate_binding_diverges_at_fire_time (crates/engine/src/game/triggers.rs) both ended in _ => false. In this module false means "both legs of the CR 603.4 hoist read this identically, so hoisting is safe", so every unclassified variant was silently asserted to be reproducible at fire time.

That is fail-open in the destructive direction. A wrong true costs a conservative re-check; a wrong false gates the ability off the stack and, for a consumed one-shot, deletes it outright (false_gate_consumes_one_shot). The same tail on the sibling QuantityRef axis was already found wrong in practice in #7389, which is why that one was made exhaustive there and this one was split out.

Both classifiers are now exhaustive and wildcard-free, matching object_scope_unbound_at_fire_time / player_scope_unbound_at_fire_time / quantity_ref_binding_diverges, so a new variant fails to compile until it is adjudicated.

filter_binding_diverges — all 54 TargetFilter variants

Newly declining, i.e. the families the tail swallowed:

Family Variants Why it diverges
Resolution-published ledgers (CR 608.2c) LastCreated, LastRevealed, LastZoneChanged, TrackedSet, TrackedSetFiltered Written by a resolution; at fire time they hold whatever an unrelated earlier resolution left. Same argument QuantityRef::TrackedSetSize already declines under.
Linked exile (CR 607.2a) ExiledBySource, ExiledCardByIndex Matches the existing QuantityRef::CardsExiledBySource / CardTypeSetSource::ExiledBySource verdicts.
Cost referent (CR 608.2k) CostPaidObject Lives on ResolvedAbility, which is None at fire time — the population-level counterpart of ObjectScope::CostPaidObject.
Choice / replacement windows (CR 609.7a, CR 615.5) ChosenDamageSource, PostReplacementSourceController, PostReplacementDamageSource, PostReplacementDamageTarget, PostReplacementDamageTargetOwner Populated only while the choice or the prevention replacement is being applied; nothing populates them at detection.

Everything else is adjudicated non-divergent with its reasoning inline: literals and snapshots, controller-derived players, source-relative reads served by the TriggerSourceContext, durable per-source/per-player choices, and the matched-event referents (which both legs read through the current_trigger_event-or-DETECTION_TRIGGER_EVENT dual path — the same argument ObjectScope::EventTarget and PlayerScope::DefendingPlayer are already non-divergent under).

A second fail-open inside the arm that was already there

TargetFilter::Typed read only tf.properties for FilterProp::Another and ignored tf.controller entirely.

ControllerRef::TargetPlayer / TargetOpponent / ParentTargetController / ParentTargetOwner / ChosenPlayer / ScopedPlayer all read ability.targets, ability.chosen_players or the per-iteration player. The fire-time FilterContext is built with ability = None and targets = &[], so TargetPlayer silently falls back to the triggering player and counts a different population — with no gate rejection to catch it. That axis is reachable through every Typed filter in the engine, which makes it the widest door into the hoist decision.

New controller_ref_binding_diverges adjudicates all 14 variants; Typed and StackAbility both consult it. This is slightly beyond the issue's literal text, but the issue asks each variant to be justified against the module's one question, and Typed cannot be answered honestly without it.

gate_binding_diverges_at_fire_time — all 57 AbilityCondition variants

Each is adjudicated on its own reading rather than on "does the bridge pass it". The old tail was inert only because ability_condition_to_static_condition happens to decline the arms it covered — a claim about a different function, silently re-underwritten every time that bridge grows an arm.

Every arm the bridge passes today (IsYourTurn, CompletedDungeon { specific: None }, SourceAttachedToCreature, ControlsCommander, QuantityCheck, Not) answers false or recurses, so this half is a pure hardening change with no behaviour difference — pinned by a test. SourceMatchesFilter / ControllerControlsMatching / WasStartingPlayer now recurse into their filter and controller payloads.

Deliberately out of scope

The FilterProp payload axis of Typed — ~90 variants, several carrying nested TargetFilters and resolution-scoped referents. It is documented as unadjudicated in the function's doc comment rather than left implicit, and wants its own pass.

Behaviour change

Only in the conservative direction: the shapes above stop being hoisted and keep today's resolution-only reading, costing CR 603.4's fire-time half for those gates. No hoist that happens today stops happening for any other reason, and nothing newly hoists.

Verification

  • cargo fmt --all clean; cargo clippy -p phase-engine --all-targets -- -D warnings clean.
  • 19,327 engine lib tests and 5,099 engine integration tests pass, 0 failures. (Tilt was not running in this worktree, so these ran directly.)
  • All CR numbers cited were grepped against docs/MagicCompRules.txt before being written.
  • New pin resolution_published_population_gate_declines_the_fire_time_hoist drives two production minimal pairs (ObjectCount{F} >= 2, differing only in F) plus per-family unit pins, and asserts the four payload-free bridging gates still hoist.
  • The existing hoist pins — divergent_gate_bindings_decline_the_fire_time_hoist, non_battlefield_presence_gate_declines_the_fire_time_hoist, resolution_scoped_quantity_gate_declines_the_fire_time_hoist — pass unchanged and still assert what they intend. They scope their populations with ControllerRef::You, which remains non-divergent, so the new controller screen does not silently relax them.

Not checked against real cards: card-data.json is gitignored and not generated in this worktree. The issue records that no card is known to reach a misclassified filter, and every change here declines rather than admits a hoist, so the worst case is a delayed trigger losing CR 603.4's fire-time half rather than one deleted off the stack.

🤖 Generated with Claude Code

…hase-rs#7406)

`filter_binding_diverges` and `gate_binding_diverges_at_fire_time` both ended
in `_ => false`. In this module `false` means "both legs of the CR 603.4 hoist
read this identically, so hoisting is safe", so every unclassified variant was
silently asserted to be reproducible at fire time. That is fail-open in the
destructive direction: a wrong `true` costs a conservative re-check, a wrong
`false` gates the ability off the stack and, for a consumed one-shot, deletes
it outright (`false_gate_consumes_one_shot`). The same tail on the sibling
`QuantityRef` axis was already found wrong in practice.

Both are now exhaustive and wildcard-free, matching the three sibling
classifiers, so a new variant fails to compile until it is adjudicated.

`filter_binding_diverges` newly declines the resolution-published population
families the tail swallowed: the `last_*_ids` anaphora, tracked sets, the
CR 607.2a linked-exile population and its order, the CR 609.7a chosen damage
source, the CR 615.5 post-replacement window, and the CR 608.2k cost-paid
referent.

`Typed` also gained the CONTROLLER axis, adjudicated by a new
`controller_ref_binding_diverges`. `ControllerRef::TargetPlayer` /
`TargetOpponent` / `ParentTarget*` / `ChosenPlayer` / `ScopedPlayer` all read
`ability.targets` / `chosen_players` / the per-iteration player, which the
fire-time `FilterContext` (built with `ability = None`, `targets = &[]`) does
not carry — it silently re-scopes the same printed population to the
triggering player instead. That axis was reachable through every `Typed`
filter while the arm read only `FilterProp::Another`.

`gate_binding_diverges_at_fire_time` answers `false` or recurses for every arm
`ability_condition_to_static_condition` can bridge today, so that half is pure
hardening with no behaviour change; the resolution-scoped arms answer `true`
on their own reading so widening the bridge cannot re-open the hole.

The `FilterProp` payload axis of `Typed` is deliberately still out of scope
and documented as such.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.


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.

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

Generated for head a05fb67446ef1016cfe97b8223ac993d4f70161e.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans matthewevans self-assigned this Aug 16, 2026
@matthewevans matthewevans added the bug Bug fix label Aug 16, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request changes — the TargetPlayer classifier is not grounded in the fire-time data model.

🔴 Blocker

[HIGH] TargetPlayer is treated as fire-time-safe even though its authority is a player target on the resolved ability. Evidence: crates/engine/src/game/triggers.rs:22065-22070 constructs ResolvedAbility with vec![] targets and claims the fire-time path would count the triggering player's creatures; crates/engine/src/game/filter.rs:1461-1467 instead resolves TargetPlayer (and TargetOpponent) solely from an actual TargetRef::Player in ability.targets. Why it matters: the fixture never exercises target-bound resolution, so it cannot establish that the classifier preserves or correctly declines the target-player population at fire time. Suggested fix: use a TargetRef::Player fixture with creature populations that distinguish trigger controller from target, and demonstrate both that fire-time evaluation has no target and that resolution reads the target-bound population; classify this controller reference conservatively until that behavior is represented correctly.

✅ Clean

The parse-diff artifact is bound to 925dfa959ac2a0d43b6ede45cc894a7af5d65934 and reports no card-parse changes.

Recommendation: request changes with the target-bound runtime proof and conservative classification above before reconsidering this hardening PR.

@matthewevans matthewevans removed their assignment Aug 16, 2026
JacobWoodson and others added 2 commits August 17, 2026 09:13
…board

Review feedback on phase-rs#7491: the `ControllerRef::TargetPlayer` half of
`resolution_published_population_gate_declines_the_fire_time_hoist` rode the
shared `run` fixture, which builds its `ResolvedAbility` with `targets: vec![]`.
With no player target on either leg, both the fire-time and resolution-time
readings fall through to the same triggering-player population, so the row
could show the decline happening but never that declining PRESERVES a correct
outcome. Its assertion message claimed a divergence the board did not exhibit.

Adds `run_target_bound`, which binds a real `TargetRef::Player` and splits the
boards so the two legs genuinely disagree:

  * the TARGET (P1) controls two creatures      -> resolution gate TRUE
  * the controller / triggering player (P0) has none, and
    `ZoneChangeRecord::test_minimal` pins the event's controller to P0, so the
    fire-time `Typed` arm falls back to P0 -> fire-time gate FALSE

It then resolves the survivor and asserts `monarch == Some(P0)`, which is the
part the old row could not state: a hoist here does not re-check, it DELETES a
one-shot whose resolution-time gate was true.

Confirmed discriminating by reclassifying `ControllerRef::TargetPlayer` as
non-divergent and re-running: `stack` drops 1 -> 0.

Also corrects the `TargetPlayer` / `TargetOpponent` comment in
`controller_ref_binding_diverges`. It said both arms "fall back to the
TRIGGERING player", which holds for `filter_inner_for_object`'s `Typed` arm but
not for `filter::controller_ref_player`, which has no such fallback and answers
`None`. Both readings diverge from the target-bound one; the comment now says
so per site.

No classification changed: `TargetPlayer` was already `true` (declines the
hoist), which is the conservative answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JacobWoodson

Copy link
Copy Markdown
Contributor Author

Thanks — the evidence pointed at a real hole in the test, fixed in 36bcd7d. One correction on the framing first, because it changes what the fix needed to be.

The classification was already the conservative one

TargetPlayer is treated as fire-time-safe

It isn't. controller_ref_binding_diverges answers true for TargetPlayer / TargetOpponent, and in this module true means diverges → decline the hoist → keep today's resolution-only reading. false is the permissive answer. So TargetPlayer already fails closed, and "classify this controller reference conservatively" is the state the PR shipped in. Before this PR it was not classified at all — Typed read only tf.properties for FilterProp::Another and never looked at tf.controller — which is the fail-open the PR closes.

The test criticism is correct, and was the actual defect

The fixture genuinely could not establish what its assertion message claimed. run builds its ResolvedAbility with targets: vec![], so ability.targets is empty at resolution too, and both legs fall through to the same triggering-player population. The row showed the decline happening; it never showed that declining preserves a correct outcome. The message asserting it "would count the TRIGGERING player's creatures instead of the target's" described a divergence that board did not exhibit.

Fixed with a dedicated run_target_bound fixture that does exactly what you asked for — binds a real TargetRef::Player and splits the populations so the two legs disagree:

  • target P1 controls two creatures → resolution-time gate reads 2 >= 2 TRUE
  • controller / triggering player P0 controls none, and ZoneChangeRecord::test_minimal pins the event's controller to PlayerId(0), so the fire-time Typed arm falls back to P0 → FALSE

It then resolves the survivor and asserts monarch == Some(PlayerId(0)). That is the claim the old row could not make: a hoist here does not re-check, it deletes a one-shot whose resolution-time gate was true.

Confirmed discriminating, rather than assumed: reclassifying ControllerRef::TargetPlayer as non-divergent and re-running drops stack from 1 to 0, with monarch == None.

Your filter.rs:1462 citation caught a wrong comment

You're right that controller_ref_player resolves TargetPlayer solely from ability.targets with no fallback. My comment said both arms "silently fall back to the TRIGGERING player", which is true of filter_inner_for_object's Typed arm (the .or_else(triggering_event_player) after the TargetRef::Player scan) but not of that site. Both readings still diverge from the target-bound one — one lands on the wrong player, the other on None — so the verdict is unchanged, but the comment now states it per site instead of over-generalising.

Verification on the rebased base

The branch picked up the phase-rs:main merge (now on v0.57.0) before this push. Re-run against that base: cargo fmt clean, cargo clippy -p phase-engine --all-targets -- -D warnings clean, 19,404 engine lib tests pass, 0 failures.

@matthewevans matthewevans self-assigned this Aug 17, 2026
@matthewevans

Copy link
Copy Markdown
Member

Held pending current-head CI evidence. The previous TargetPlayer test-scope blocker is resolved on 36bcd7dc206fea0516a8dcffe7631a935ca6697f; the new target-bound fixture distinguishes the resolution-time target population from the triggering-player population.

Approval and merge-queue enrollment are paused because the available check-artifact downloads for this head are failing externally with HTTP 429, and the parse-diff sticky comment is still bound to prior head a05fb67446ef1016cfe97b8223ac993d4f70161e, not this head. The next maintainer sweep will retry the action artifacts and wait for a parse-diff comment generated for 36bcd7dc206fea0516a8dcffe7631a935ca6697f; once both are current, this PR will return to approval review. No contributor change is requested for this hold.

@matthewevans matthewevans removed their assignment Aug 17, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request changes — the current head is still not fail-closed across the Typed filter surface.

🔴 Blocker

crates/engine/src/game/triggers.rs:10715-10721 explicitly leaves the FilterProp payload axis unadjudicated, while filter_binding_diverges at :10727-10735 returns false for every property except Another. That includes payloads such as SharesQuality, InTrackedSet, and SameNameAsParentTarget that can depend on a nested/resolution-bound referent. A delayed trigger can therefore still hoist a condition whose fire-time and resolution-time populations differ—the failure mode this PR is meant to eliminate.

Extend the same authority with a recursive property-level divergence classifier (including nested filters and resolution-bound referents), then add a target-bound production-path regression that fails when that classifier is removed. The existing TargetPlayer case is a good shape, but it does not cover the omitted property axis.

✅ Clean

The current TargetPlayer fixture does repair the previous review’s specific target-binding test gap: it reaches the real delayed-trigger path and distinguishes the two player populations.

Recommendation: request changes — complete the Typed property axis before claiming the hoist classifiers fail closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Engine: filter_binding_diverges' _ => false tail is fail-open for the CR 603.4 delayed-trigger hoist

2 participants