Skip to content

feat(engine): journal the CR 603.3d uncommitted-trigger removal - #6667

Merged
matthewevans merged 1 commit into
mainfrom
cr733/journal-trigger-removal
Jul 26, 2026
Merged

feat(engine): journal the CR 603.3d uncommitted-trigger removal#6667
matthewevans merged 1 commit into
mainfrom
cr733/journal-trigger-removal

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 26, 2026

Copy link
Copy Markdown
Member

CR 603.3d: "If a choice is required when the triggered ability goes on the stack
but no legal choices can be made for it, or if a rule or a continuous effect
otherwise makes the ability illegal, the ability is simply removed from the
stack."

The engine reaches that removal through the "push first, choose second"
invariant: a triggered ability is PUT on the stack and only then are its choices
gathered, with pending_trigger_entry marking the entry whose slots are still
unfilled. Declining an optional MODAL trigger before its mode choice abandons
one, and #6664 funnelled all six sites through a single authority. This journals
that authority.

TWO OUTCOMES, both mutating, which is why removed is an Option rather than a
bare entry. The authority consumes pending_trigger_entry UNCONDITIONALLY and
only then decides whether to pop:

  • guard holds — cursor consumed AND the entry leaves with both side tables
  • guard fails — cursor consumed and nothing else, because the cursor outlived
    its entry (another path already removed it)

A command modelling only the first would leave a replay of the second holding a
pending_trigger_entry the real execution had cleared — a divergence needing no
forged journal, only an honest replay. The applier therefore also REFUSES a
removed: None record whose predecessor still has the entry on top: replaying
there would clear the cursor and strand the entry, a state no execution produces.

The removed side-table VALUES are deliberately not recorded. Contrast
ResolvedStackEntryFinalizeCommand, which records expected_old_paid_facts
because it INSTALLS a value and must verify what it overwrites. This command only
removes rows keyed on the recorded entry's own id — nothing is installed and
nothing re-derived, so there is no invariant a recorded value would pin, and
carrying Vec<GameEvent> batches would widen every journal entry for nothing.

The applier compares the popped entry WHOLE rather than by id. ObjectId is
reused across a replay, so an id-only match would discard a divergent entry that
merely shares an id and report success.

Fixture note, because three plausible cards do NOT reach this authority and each
failure is silent. Measured:

  • you may choose one — ... reaches it; entry popped.
  • choose one — ... reaches mid-construction, never removed (no
    may-offer to decline). Control.
  • you may destroy target creature never reaches it at all — a "you may" on
    an effect is resolved under CR 608.2d when the
    ability RESOLVES, so the ability is fully
    constructed when pushed and declining it makes it
    do nothing rather than removing it.

Revert probes, each watched go red and restored:

  1. journal call removed -> 0 passed / 3 failed, left: 0, right: 1. Proves
    every test reaches the production journal path.
  2. applier matches by id instead of whole entry -> 2 passed / 1 failed, only
    removal_rejects_a_divergent_predecessor. Proves that test is
    discriminating rather than incidentally coupled.

CR 603.3d and CR 603.3c grep-verified against docs/MagicCompRules.txt.
Engine integration suite 4064 passed, 0 failed. clippy -p engine --lib clean.

Summary by CodeRabbit

  • New Features

    • Added journaled tracking for removal of uncommitted triggered abilities.
    • Recorded outcomes now distinguish between entries removed from the stack and cursor-only consumption.
    • Added replay safeguards to detect inconsistent or invalid game states.
  • Bug Fixes

    • Related trigger data is now cleared when an uncommitted ability is removed.
    • Replay reliably restores the correct stack depth and pending-trigger state.
  • Tests

    • Added coverage for removal, replay, cursor handling, and invalid predecessor states.

CR 603.3d: "If a choice is required when the triggered ability goes on the stack
but no legal choices can be made for it, or if a rule or a continuous effect
otherwise makes the ability illegal, the ability is simply removed from the
stack."

The engine reaches that removal through the "push first, choose second"
invariant: a triggered ability is PUT on the stack and only then are its choices
gathered, with `pending_trigger_entry` marking the entry whose slots are still
unfilled. Declining an optional MODAL trigger before its mode choice abandons
one, and #6664 funnelled all six sites through a single authority. This journals
that authority.

TWO OUTCOMES, both mutating, which is why `removed` is an `Option` rather than a
bare entry. The authority consumes `pending_trigger_entry` UNCONDITIONALLY and
only then decides whether to pop:

  * guard holds — cursor consumed AND the entry leaves with both side tables
  * guard fails — cursor consumed and nothing else, because the cursor outlived
    its entry (another path already removed it)

A command modelling only the first would leave a replay of the second holding a
`pending_trigger_entry` the real execution had cleared — a divergence needing no
forged journal, only an honest replay. The applier therefore also REFUSES a
`removed: None` record whose predecessor still has the entry on top: replaying
there would clear the cursor and strand the entry, a state no execution produces.

The removed side-table VALUES are deliberately not recorded. Contrast
`ResolvedStackEntryFinalizeCommand`, which records `expected_old_paid_facts`
because it INSTALLS a value and must verify what it overwrites. This command only
removes rows keyed on the recorded entry's own id — nothing is installed and
nothing re-derived, so there is no invariant a recorded value would pin, and
carrying `Vec<GameEvent>` batches would widen every journal entry for nothing.

The applier compares the popped entry WHOLE rather than by id. `ObjectId` is
reused across a replay, so an id-only match would discard a divergent entry that
merely shares an id and report success.

Fixture note, because three plausible cards do NOT reach this authority and each
failure is silent. Measured:
  * `you may choose one — ...`  reaches it; entry popped.
  * `choose one — ...`          reaches mid-construction, never removed (no
                                may-offer to decline). Control.
  * `you may destroy target creature`  never reaches it at all — a "you may" on
                                an effect is resolved under CR 608.2d when the
                                ability RESOLVES, so the ability is fully
                                constructed when pushed and declining it makes it
                                do nothing rather than removing it.

Revert probes, each watched go red and restored:
  1. journal call removed -> 0 passed / 3 failed, `left: 0, right: 1`. Proves
     every test reaches the production journal path.
  2. applier matches by id instead of whole entry -> 2 passed / 1 failed, only
     `removal_rejects_a_divergent_predecessor`. Proves that test is
     discriminating rather than incidentally coupled.

CR 603.3d and CR 603.3c grep-verified against docs/MagicCompRules.txt.
Engine integration suite 4064 passed, 0 failed. `clippy -p engine --lib` clean.
@matthewevans
matthewevans enabled auto-merge July 26, 2026 12:59
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a resolved journal command for uncommitted triggered-ability removal, records pop and no-pop outcomes, validates replay invariants, updates replay dispatch, and adds integration coverage for exact, divergent, and cursor-only removal cases.

Changes

Uncommitted trigger removal

Layer / File(s) Summary
Resolved command and journal contract
crates/engine/src/types/resolved_commands.rs, crates/engine/src/types/mod.rs
Adds the removal command, replay error type, journal API, enum variant, exports, and command validation.
Stack removal and replay application
crates/engine/src/game/stack.rs
Consumes the pending cursor, conditionally removes the stack entry and side tables, journals the outcome, and validates replay invariants.
Integration replay coverage
crates/engine/tests/integration/cr733_resolved_commands_p2.rs, crates/engine/tests/integration/cr733_resolved_draw.rs, crates/engine/tests/integration/cr733_resolved_trigger_removal.rs, crates/engine/tests/integration/main.rs
Adds replay dispatch support and tests exact removal, divergent predecessors, and no-pop cursor consumption.

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

Sequence Diagram(s)

sequenceDiagram
  participant TriggerResolution
  participant Stack
  participant Journal
  participant ReplayState
  TriggerResolution->>Stack: decline optional trigger
  Stack->>Stack: consume cursor and conditionally pop entry
  Stack->>Journal: record removal outcome
  ReplayState->>Stack: apply recorded command
  Stack->>Stack: validate predecessor invariants
  Stack-->>ReplayState: updated stack and cursor state
Loading

Possibly related PRs

Suggested reviewers: andriypolanski, jsdevninja

🚥 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 matches the main change: journaling CR 603.3d uncommitted-trigger removal in the engine.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 cr733/journal-trigger-removal

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

🤖 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/tests/integration/cr733_resolved_trigger_removal.rs`:
- Around line 238-286: The test currently hand-constructs a no-pop removal
instead of exercising the production guard-failure path. Extend the fixture
around a_removal_that_popped_nothing_still_clears_the_cursor to remove the
pending-trigger entry through a real replacement/SBA sweep or competing decline
before invoking pop_uncommitted_pending_trigger_entry, then obtain and replay
the resulting no-pop command. Keep assertions that the cursor clears and the
stack remains unchanged, while ensuring the production path—not a manually built
ResolvedUncommittedTriggerRemovalCommand—produces the record.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c5adf83-508c-4e2d-8f2d-8116eec790b4

📥 Commits

Reviewing files that changed from the base of the PR and between 7be8d00 and 3a5bbc8.

📒 Files selected for processing (7)
  • crates/engine/src/game/stack.rs
  • crates/engine/src/types/mod.rs
  • crates/engine/src/types/resolved_commands.rs
  • crates/engine/tests/integration/cr733_resolved_commands_p2.rs
  • crates/engine/tests/integration/cr733_resolved_draw.rs
  • crates/engine/tests/integration/cr733_resolved_trigger_removal.rs
  • crates/engine/tests/integration/main.rs

Comment on lines +238 to +286
fn a_removal_that_popped_nothing_still_clears_the_cursor() {
let state = declined_optional_trigger_state();
let commands = removals(&state);
let popping = &commands[0];

let no_pop = ResolvedUncommittedTriggerRemovalCommand {
consumed_entry_id: popping.consumed_entry_id,
removed: None,
resulting_depth: state.stack.len(),
cause: popping.cause,
};

// Predecessor whose top IS that entry: a no-pop record is a divergence.
let mut still_present = pre_removal_state(&state, popping);
assert!(
matches!(
apply_resolved_uncommitted_trigger_removal(&mut still_present, &no_pop),
Err(ResolvedUncommittedTriggerRemovalReplayInvariantError::DepthMismatch { .. })
| Err(
ResolvedUncommittedTriggerRemovalReplayInvariantError::UnexpectedRemovableEntry(
_
)
)
),
"a no-pop record must not apply where the entry is still removable"
);
assert_eq!(
still_present.pending_trigger_entry,
Some(popping.consumed_entry_id),
"the rejected replay must not have consumed the cursor"
);

// Predecessor where the entry has already gone: the same record applies and
// clears the cursor without touching the stack.
let mut already_gone = state.clone();
already_gone.pending_trigger_entry = Some(popping.consumed_entry_id);
let depth_before = already_gone.stack.len();
apply_resolved_uncommitted_trigger_removal(&mut already_gone, &no_pop)
.expect("a no-pop removal replays where the entry is already gone");
assert_eq!(
already_gone.pending_trigger_entry, None,
"the cursor is consumed even when nothing was popped"
);
assert_eq!(
already_gone.stack.len(),
depth_before,
"a no-pop removal must not touch the stack"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

No production-path coverage for the "guard fails" branch.

a_removal_that_popped_nothing_still_clears_the_cursor proves apply_resolved_uncommitted_trigger_removal's None branch replays correctly, but the no_pop command is hand-constructed (Lines 243-248) rather than journaled from a real scenario. Nothing in this file drives pop_uncommitted_pending_trigger_entry (stack.rs Lines 302-309) through a genuine "cursor outlived its entry" case — i.e., the state.stack.back().map(|e| e.id) == Some(entry_id) guard evaluating to false in production. If that guard's condition were ever flipped or the entry lookup broken, no test here would catch it, since only the replay side is exercised for this outcome.

Consider adding a fixture where the pending-trigger entry is removed via a different path (e.g., a replacement/SBA sweep, or a second decline racing the same cursor) before pop_uncommitted_pending_trigger_entry runs, so the "no-pop" record is observed coming out of the real authority rather than assembled by hand.

As per path instructions, "A test must exercise the FAILURE path the fix prevents and drive the engine through its production pipeline... Flag constructor shortcuts... that can silently mask the very bug a regression test claims to catch."

🤖 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/cr733_resolved_trigger_removal.rs` around
lines 238 - 286, The test currently hand-constructs a no-pop removal instead of
exercising the production guard-failure path. Extend the fixture around
a_removal_that_popped_nothing_still_clears_the_cursor to remove the
pending-trigger entry through a real replacement/SBA sweep or competing decline
before invoking pop_uncommitted_pending_trigger_entry, then obtain and replay
the resulting no-pop command. Keep assertions that the cursor clears and the
stack remains unchanged, while ensuring the production path—not a manually built
ResolvedUncommittedTriggerRemovalCommand—produces the record.

Source: Path instructions

@matthewevans
matthewevans added this pull request to the merge queue Jul 26, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

Merged via the queue into main with commit 82d5b78 Jul 26, 2026
15 checks passed
@matthewevans
matthewevans deleted the cr733/journal-trigger-removal branch July 26, 2026 13:42
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.

1 participant