Skip to content

fix(engine): separate carrier well-formedness from settle readiness - #7067

Merged
matthewevans merged 1 commit into
mainfrom
fix/6892-settle-gate-layer-separation
Aug 7, 2026
Merged

fix(engine): separate carrier well-formedness from settle readiness#7067
matthewevans merged 1 commit into
mainfrom
fix/6892-settle-gate-layer-separation

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 6, 2026

Copy link
Copy Markdown
Member

What this is

Defensive hardening of the resolving-carrier settle gate: it separates a well-formedness assertion from a readiness predicate.

resolving_stack_entry_can_settle included this conjunct:

&& state.resolving_trigger_firing.is_some()
    == state.resolving_stack_entry.as_ref()
        .is_some_and(|entry| matches!(&entry.kind, StackEntryKind::TriggeredAbility { .. }))

That is a well-formedness test living inside a readiness predicate. If the carrier/firing pairing is ever incoherent, the gate returns false, the state reads as "not finished," and the sweep that exists to clear the carrier is disabled by the very condition it is supposed to clear. The malformed carrier is the one input the sweep can never act on.

What changed

One file, crates/engine/src/game/engine.rs (+274 / −6):

  • Removed the well-formedness conjunct from the readiness gate. Every readiness conjunct is retained.
  • Extracted resolving_carrier_is_triggered and resolving_carrier_parity_is_coherent as diagnostics, consumed at three sites rather than duplicated.
  • Added a tracing::warn! on the settle path when the pairing is incoherent, since debug_assert! is compiled out at the shipped opt-level = 'z'.
  • Kept the existing debug_assert! pointed at readiness, deliberately. Repointing it at the parity predicate would panic in debug on exactly the malformed input this change exists to tolerate.

CR annotations (each verified against docs/MagicCompRules.txt before being written): CR 113.3c, CR 603.7, CR 608.2c, CR 608.2m, CR 704.3.

Hot path: the gate runs at every priority boundary and now evaluates one fewer conjunct, so per-boundary cost strictly decreases. The parity computation moves to the settle path, which runs only when a resolution actually completes.

Relationship to #6892 — please read

This is not a fix for #6892, and #6892 should not be closed by this PR.

The issue was triaged as an invariant violation: resolving_stack_entry.kind == TriggeredAbility with resolving_trigger_firing == null. That diagnosis is anachronistic. I recovered the reported capture and replayed it against main:

Triage claim Actual capture bytes
resolving_trigger_firing: null key absent — schema absence, not a null
resolved_rules_journal: 6 entries 35 entries
invariant violated the field, its type, and the gate did not exist in that build

resolving_trigger_firing and TriggerFiring arrived in 8121fd1c6a (#6842, 2026-08-01); resolving_stack_entry_can_settle in 4099566716 (#6933, 2026-08-02). The capture is dated 2026-07-23 — nine days earlier.

Replayed through the production restore path on current main, the capture heals: resolving_stack_entry comes back None, because normalize_legacy_completed_resolution_carrier matches that shape exactly. The reported stuck state does not reproduce.

Severity

Lower than the issue claims, and stated accordingly in every comment and test name here:

  • No production read of resolving_stack_entry blocks priority or legal actions. The gating reads require waiting_for to be a resolution-choice prompt, not Priority.
  • In release, begin_resolving_stack_entry assigns both fields unconditionally, so the next resolution overwrites a leaked carrier and restores parity.

The honest benefit is "the sweep can do its job on malformed input" — not a softlock fix. The words "softlock", "permanent", and "P0" appear nowhere in this change.

Tests

Four inline #[cfg(test)] tests. resolving_trigger_firing is pub(crate) and both settle wrappers are pub(super), so an external test crate cannot reach them.

Test Role
a_triggered_carrier_missing_its_firing_still_settles_at_the_priority_boundary the discriminating test — red before the change
a_suspended_resolution_still_blocks_settling_for_either_pairing non-vacuity: only the well-formedness conjunct was removed
no_carrier_means_nothing_to_settle non-vacuity: settling did not become unconditional
parity_predicate_classifies_every_carrier_pairing direct coverage of the extracted diagnostic

The two non-vacuity probes are load-bearing: without them the discriminating test is satisfiable by a gate that simply always settles. Negative control was run — reverting the conjunct removal alone turns the first test red.

Approaches considered and rejected

  • Loosening the allow_unlabeled_v1_carriers decode gate — would universalize a fabrication that silently downgrades Ordinary/ReceiptEligible firings to LegacyDelayed.
  • Substituting UnknownLegacyvalidate_firing returns Err for it, so this would turn genuine v1 restores into hard decode errors.

Neither reached code.

Verification

cargo fmt --check, cargo clippy -D warnings, and the full phase-engine suite all pass at this commit (18557 + 4587 + 12 + 9 tests, 0 failures), run in an isolated CARGO_TARGET_DIR. The CR 603.5 prompt census pin was re-derived by content hash after rebase and re-verified.

No parser impact, no coverage impact, no card-data regeneration.

Summary by CodeRabbit

  • Bug Fixes
    • Improved settlement handling for completed carriers, including malformed or inconsistent configurations.
    • Completed settlements are now cleared even when carrier details do not match expected trigger conditions.
    • Added clearer warnings for inconsistent carrier pairings.
    • Improved diagnostics for suspended resolutions and missing carriers.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@matthewevans, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 22 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 05e7615b-a47e-42df-8901-85017233166b

📥 Commits

Reviewing files that changed from the base of the PR and between d424bdf and 5188955.

📒 Files selected for processing (1)
  • crates/engine/src/game/engine.rs
📝 Walkthrough

Walkthrough

Changes

Settlement readiness

Layer / File(s) Summary
Separate settlement completion from carrier parity
crates/engine/src/game/engine.rs
Settlement now checks resolution completion independently from carrier parity. Incoherent completed carriers emit a warning but still clear. Diagnostic line tracking was updated.
Cover settlement and parity states
crates/engine/src/game/engine.rs
Tests cover malformed completed carriers, suspended resolutions, missing carriers, and all carrier/firing combinations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested labels: bug

Suggested reviewers: lgray, jacobwoodson, mike-thedude

🚥 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 identifies the main change: separating carrier well-formedness from settlement readiness 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 fix/6892-settle-gate-layer-separation

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.

`resolving_stack_entry_can_settle` conjoined a structural invariant with its
readiness conditions:

    state.resolving_trigger_firing.is_some()
        == entry.kind is TriggeredAbility

That equality is true for every well-formed state, so it never gated anything
it was meant to. On a carrier whose firing classification is desynchronized
from its entry kind it evaluates false, the predicate reports "not finished",
and both `pub(super)` settle wrappers become no-ops — disabling the very sweep
that exists to clear such a carrier. CR 608.2c and CR 608.2m make readiness a
question about whether the resolution has finished following its instructions;
whether the carrier is well-formed is a separate question and does not belong
in that predicate.

The conjunct is lifted out into `resolving_carrier_parity_is_coherent`
(CR 603.7 — a firing classification is private to a triggered carrier), which
is reported on the settle path rather than gating it. The `debug_assert!` is
compiled out at the shipped `opt-level = 'z'`, so the violation also gets a
release-visible `tracing::warn!`; the carrier settles either way, since the
resolution it owns has finished regardless and withholding the sweep would only
leave it behind.

The gate loses a conjunct, so per-priority-boundary cost (CR 704.3) strictly
decreases. Parity is computed on the settle path, which runs only when a
resolution actually completes. No new state, field, flag, or enum variant.

Scope note: this is defensive hardening, not a fix for the state reported in
 #6892. That capture was recovered and replayed against this base — it decodes
and heals, because `normalize_legacy_completed_resolution_carrier` clears
exactly that shape. The capture also predates the fields involved
(`resolving_trigger_firing` arrived in 8121fd1, the settle gate in
4099566; the capture is 2026-07-23) and carries the firing keys as absent
rather than null. No production read of `resolving_stack_entry` is known to
block priority or legal actions, and in release the next resolution overwrites
a leaked carrier — so the benefit here is that the sweep can do its job on
malformed input, not that a stuck game unlocks.

Four inline tests. The discriminating one asserts a desynchronized triggered
carrier settles at the priority boundary; it was verified red on revert of the
conjunct removal alone, with the three non-vacuity probes staying green
(readiness intact for both pairings, leading `is_some()` conjunct intact, and
all six carrier/firing pairings classified).

Also updates the CR 603.5 prompt-census pin `game/engine.rs:11712 -> :11747`
with a drift-log entry. Pure local line movement: the producer is
sha256-identical (`8a544e878d3e77fb...`), unique in the file, still inside
`begin_pending_trigger_target_selection`, and the set is unchanged at five
producers (total 37, partition 5/7/25).

Refs #6892
@matthewevans
matthewevans force-pushed the fix/6892-settle-gate-layer-separation branch from 26fe17a to 5188955 Compare August 7, 2026 02:26
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Generated for head 51889555603616e1d7c8ca27e9600ddf053d70fe.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans
matthewevans merged commit 452216a into main Aug 7, 2026
13 checks passed
@matthewevans
matthewevans deleted the fix/6892-settle-gate-layer-separation branch August 7, 2026 02:49
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