Skip to content

ship/ai reliability fixes - #6842

Merged
matthewevans merged 17 commits into
mainfrom
ship/ai-reliability-fixes
Aug 1, 2026
Merged

ship/ai reliability fixes#6842
matthewevans merged 17 commits into
mainfrom
ship/ai-reliability-fixes

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 31, 2026

Copy link
Copy Markdown
Member
  • fix(ai): price delivered draw benefits exactly
  • fix(ai): reject lethal life-cost candidates
  • fix(engine): preserve delayed trigger firing provenance
  • fix(ai): choose Evoke only for proven utility
  • feat(ai): plan fetchland mana routes
  • fix(ai): improve mana, mulligan, and swarm decisions
  • fix(ai): certify Pact upkeep payment routes
  • style(engine): format swarm witness

Summary by CodeRabbit

  • New Features
    • Improved AI decision-making for combat, mana development, Evoke choices, delayed-payment spells, and mulligans.
    • Added certified previews for life-payment safety and draw delivery.
    • Added conservative combat analysis to identify lethal attacks while avoiding uncertain situations.
  • Bug Fixes
    • Improved delayed-trigger tracking, persistence, and replay validation.
    • Prevented private engine analysis details from appearing in client-facing game state.
    • Improved handling of ambiguous interactions and casting choices through safer abstention.

@matthewevans
matthewevans enabled auto-merge July 31, 2026 16:46
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds reducer-backed Evoke, prospective-mana, Pact, swarm, draw, and life-safety analysis. It adds delayed-trigger provenance across persistence, replay, stacking, and resolution. It integrates certified routes into AI search, policies, combat selection, mulligan forecasting, and public scoring APIs.

Changes

Engine state and certified previews

Layer / File(s) Summary
Delayed-trigger identity and lifecycle
crates/engine/src/types/*, crates/engine/src/game/triggers.rs, crates/engine/src/game/stack.rs
Delayed-trigger tokens, provenance, and firing classifications now persist through restoration, replay, dispatch, stacking, continuation, cleanup, and resolution.
Preview and life-safety boundaries
crates/engine/src/game/engine.rs, crates/engine/src/game/life_safety.rs, crates/engine/src/game/effects/*
The engine adds clone-local life-safety previews, exact draw-delivery previews, mutation receipts, and pre-reconciliation reducer execution.
Casting and state visibility
crates/engine/src/game/casting.rs, crates/engine/src/game/casting_costs.rs, crates/engine/src/game/derived_views.rs, crates/engine/src/game/visibility.rs
Casting exposes validated Evoke offers and entry projections. Client and viewer projections remove private authority fields.

Certified engine AI support

Layer / File(s) Summary
Evoke prompt analysis
crates/engine/src/ai_support/evoke.rs
Evoke prompts validate live casting choices, project entry state, and classify supported immediate effects. Unsupported or ambiguous effects return Unknown.
Prospective mana routes
crates/engine/src/ai_support/prospective_mana.rs
Fetch-then-cast and Pact routes use frozen candidates, authenticated bindings, bounded reducer simulations, provenance checks, and indeterminate outcomes.
Swarm combat witness
crates/engine/src/ai_support/swarm.rs
Combat witness generation replays bounded attacker and blocker declarations and returns certified worst-case life loss or an indeterminate result.

Phase AI integration

Layer / File(s) Summary
Durable routes and scoring
crates/phase-ai/src/session.rs, crates/phase-ai/src/search.rs, crates/phase-ai/src/planner/mod.rs, crates/engine-wasm/src/lib.rs
Search stores and redeems certified fetch/Pact routes. Candidate scoring uses continuation witnesses and the parallel-worker API. Score selection uses the safe selector.
Policies and effect valuation
crates/phase-ai/src/policies/*
Anti-self-harm rejects lethal committed life payments and uncertified Pact obligations. Draw pricing uses exact delivery previews. Effect and X-cast classification use shared thresholds and grouped residual analysis.
Mulligan and combat selection
crates/phase-ai/src/policies/mulligan/*, crates/phase-ai/src/combat_ai.rs
Opening hands are certified dead only when no legal land, mana, or normal action exists. Combat AI promotes only reducer-certified lethal swarm declarations.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: bug, enhancement, quality

🚥 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 accurately describes the PR's broad focus on AI reliability fixes, although it does not identify the specific engine and decision improvements.
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 ship/ai-reliability-fixes

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.

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

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/game/engine.rs (1)

9049-9053: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Two dead-branch trigger drops are missing the pending_trigger_firing clear added everywhere else in this function.

In begin_pending_trigger_target_selection, three sibling sites pop the uncommitted trigger entry and clear state.pending_trigger. Two of them were updated by this PR to also clear the new field, but two others were not:

  • L9015-9018 (modal, modal_choice_with_target_assignment_limit returns None): updated, clears pending_trigger_firing.
  • L9035-9038 (random-selection defensive drop): updated, clears pending_trigger_firing.
  • L9049-9053 (modal, "all modes unavailable"): NOT updated.
  • L9165-9167 (non-modal, no legal target — the comment explicitly says Symmetric to the modal all-modes-unavailable branch above: if the "push first" dispatcher already pushed an in-construction entry for this trigger, pop it before clearing the cursor.): NOT updated.

The comment at L9046-9048 calls the first missed branch a dead branch — kept as a defensive cleanup for any delayed-revalidation paths, but a latent bug behind a guard is still a bug if that guard is ever removed or the branch is reached. Since state.pending_trigger_firing is read directly and independently elsewhere (e.g. the Pact-plan tracker checks it without gating on pending_trigger.is_some()), leaving it stale after abandoning a trigger under construction can make a dropped trigger's firing classification (potentially Delayed(Some(provenance))) still appear live.

🐛 Proposed fix: clear `pending_trigger_firing` at both remaining drop sites
             if unavailable_modes.len() >= modal.mode_count {
                 super::stack::pop_uncommitted_pending_trigger_entry(state);
                 state.pending_trigger = None;
+                state.pending_trigger_firing = None;
                 return Ok(None);
             }
     let Some((target_slots, selection)) = selection_result? else {
         super::stack::pop_uncommitted_pending_trigger_entry(state);
         state.pending_trigger = None;
+        state.pending_trigger_firing = None;
         return Ok(None);
     };

Also applies to: 9158-9168

🤖 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/engine.rs` around lines 9049 - 9053, In
begin_pending_trigger_target_selection, clear state.pending_trigger_firing at
both remaining trigger-drop sites: the modal all-modes-unavailable branch near
unavailable_modes.len() and the non-modal no-legal-target branch. Keep the
existing pop_uncommitted_pending_trigger_entry and pending_trigger cleanup
intact, ensuring every abandoned trigger path resets the firing state.
crates/engine/src/game/stack.rs (1)

3318-3339: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Preserve trigger firing during batched resolution

resolve_batched discards each PoppedStackEntry.trigger_firing after clearing resolving_trigger_firing. A batchable delayed Effect::Token can therefore execute without its TriggerFiring::Delayed provenance, causing Pact tracking to treat the obligation as expired. Preserve the firing classification before plan.execute. Because BatchRunKey excludes it, also prevent mixed ordinary and delayed entries from sharing a batch.

🤖 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/stack.rs` around lines 3318 - 3339, Update
resolve_batched to retain each popped entry’s trigger_firing and pass the
preserved classification through plan.execute so delayed Effect::Token entries
remain associated with TriggerFiring::Delayed. Also update batch grouping and
BatchRunKey construction to include trigger-firing classification, preventing
ordinary and delayed entries from sharing a batch.
🟠 Major comments (25)
crates/engine/src/ai_support/evoke.rs-250-259 (1)

250-259: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Constrain the stack-origin ChangeZone arm to removal destinations.

The first ChangeZone arm matches on origin: Some(Zone::Stack) only. It ignores destination. Any stack-origin zone change is therefore classified as ImmediateEffect::ExileStackObject, and candidate_is_opponent_stack_object approves any opposing stack object. A "put that card onto the battlefield under its owner's control" or "return target spell to its owner's hand" effect then reports ProvenUseful, which is wrong for the battlefield case and mislabeled for the others.

Match the destination explicitly so a non-removal destination falls through to None and yields Unknown.

🐛 Proposed fix
         Effect::ChangeZone {
             origin: Some(Zone::Stack),
+            destination: Zone::Exile | Zone::Graveyard,
             target,
             ..
         } => Some(ImmediateEffect::ExileStackObject(target)),

Choose the destination set that matches the value you intend to certify. If a bounce or library-shuffle stack removal should also count, add those destinations and rename the variant to reflect the class.

🤖 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/ai_support/evoke.rs` around lines 250 - 259, Update the
stack-origin `Effect::ChangeZone` arm that produces
`ImmediateEffect::ExileStackObject` to require an explicit removal destination,
such as `Zone::Exile`, so battlefield or hand destinations fall through to
`None` and remain `Unknown`. Preserve the existing `ExilePermanent` handling for
exile destinations.
crates/engine/src/game/derived_views.rs-394-434 (1)

394-434: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Route server state messages through the client-safe serializer.

GameStarted and StateUpdate serialize filtered GameState values directly, bypassing ClientGameStateRef. This exposes delayed-trigger allocators, firing maps, and delayed_triggers[].provenance that client_state_wire_value removes. Move this redaction into filter_state_for_viewer or enforce one client-safe serialization path.

🤖 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/derived_views.rs` around lines 394 - 434, Update the
server-state message serialization used by GameStarted and StateUpdate so it
routes GameState through the client-safe path, rather than serializing filtered
GameState values directly. Reuse ClientGameStateRef/client_state_wire_value or
move the equivalent redaction into filter_state_for_viewer, ensuring
delayed-trigger allocators, firing maps, resolved_rules_journal, and
delayed_triggers[].provenance are excluded from client payloads.

Source: Path instructions

crates/engine/src/game/derived_views.rs-439-463 (1)

439-463: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Make client-state redaction compiler-enforced.

A renamed or newly added private carrier can bypass this key list and leak trigger authority. A future unrelated field with one of these names can also be removed silently. serde_json::to_value(state) additionally allocates and traverses the full state for every client emission. Use an explicit client projection or per-type serialization redaction.

🤖 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/derived_views.rs` around lines 439 - 463, Replace
redact_private_trigger_firing’s recursive key-based filtering with an explicit
client-state projection or per-type serialization redaction so private
trigger-firing carriers are excluded by the type system. Update the client
emission path to serialize that projection directly, avoiding
serde_json::to_value(state) and the full-state allocation/traversal; ensure
renamed or newly added private fields cannot be emitted accidentally.
crates/phase-ai/src/policies/mulligan/mod.rs-109-114 (1)

109-114: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The all-false forecast value means both "no action found" and "never probed". Because Self::default() satisfies is_certified_dead_landless(), an unprobed hand is certified dead, and a test that supplies no hand cannot distinguish a real dead certification from the unprobed fallback.

  • crates/phase-ai/src/policies/mulligan/mod.rs#L109-L114: return None (or add a probed field) instead of Self::default(), and make card_floor.rs treat the unprobed case as keepable.
  • crates/phase-ai/src/policies/mulligan/card_floor.rs#L287-L303: add a card to the hand and assert the hand is non-empty, so the dead classification comes from an engine legality query and not from the fallback.
🤖 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/phase-ai/src/policies/mulligan/mod.rs` around lines 109 - 114, The
mulligan forecast currently treats an unprobed hand as certified dead; update
the player lookup in the forecast implementation at
crates/phase-ai/src/policies/mulligan/mod.rs:109-114 to represent the unprobed
case distinctly, such as returning None or tracking a probed state, and make the
related card-floor logic treat it as keepable. In
crates/phase-ai/src/policies/mulligan/card_floor.rs:287-303, add a card to the
hand and assert it is non-empty so dead classification comes from the engine
legality query rather than the fallback.
crates/phase-ai/src/policies/mulligan/mod.rs-851-869 (1)

851-869: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The fast-mana row does not exercise usable_nonland_mana_source.

add_zero_cost_mana_source calls add_zero_cost_action first, so the object already carries a castable {0} Spell ability. The added mana ability sets cost = AbilityCost::Tap but leaves activation_zone unset, unlike add_unaffordable_hand_activation, which sets Some(Zone::Hand). An ability with no hand activation zone produces no ActivateAbility action for a card in hand. The keep therefore comes from normal_action through the inherited {0} spell, not from usable_nonland_mana_source. That field has no test that proves it.

Set activation_zone = Some(Zone::Hand) on the mana ability, build the fixture without the {0} spell ability, and assert the forecast field directly.

💚 Suggested fixture and assertion change
     fn add_zero_cost_mana_source(state: &mut GameState, idx: u64) -> ObjectId {
-        let object_id = add_zero_cost_action(state, idx);
+        let object_id = add_hand_card(state, idx, "Zero-Cost Mana Source", vec![CoreType::Artifact]);
         let mut ability = AbilityDefinition::new(
             AbilityKind::Activated,
             engine::types::ability::Effect::Mana {
         ability.cost = Some(AbilityCost::Tap);
+        ability.activation_zone = Some(Zone::Hand);
+        let fast_mana_forecast =
+            OpeningHandActionForecast::for_hand(&[fast_mana], &fast_mana_state);
+        assert!(
+            fast_mana_forecast.usable_nonland_mana_source,
+            "the fixture must witness a hand-activatable mana source, not a castable spell"
+        );
         let fast_mana_decision = registry.evaluate_hand(

Based on the path instruction that a test must exercise the failure path the fix prevents and drive the engine through its production pipeline.

🤖 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/phase-ai/src/policies/mulligan/mod.rs` around lines 851 - 869, Update
the fast-mana test fixture around add_zero_cost_mana_source so it contains only
the mana ability, with activation_zone set to Some(Zone::Hand), and no inherited
zero-cost Spell ability. Drive the hand through registry.evaluate_hand and
assert the resulting forecast’s usable_nonland_mana_source field directly,
preserving coverage of the failure path this test is intended to validate.

Source: Path instructions

crates/phase-ai/src/policies/mulligan/mod.rs-176-190 (1)

176-190: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include Foretell and PlayFaceDown in the opening-action classifier. Both are legal priority actions from cards in hand. Their omission can classify a viable hand as certified dead. The existing CastSpell arm already covers Adventure, Disturb, Plot, and other casting variants.

🤖 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/phase-ai/src/policies/mulligan/mod.rs` around lines 176 - 190, The
is_normal_opening_hand_action classifier must recognize GameAction::Foretell and
GameAction::PlayFaceDown as normal opening-hand actions. Add both variants to
the hand-object matching arm, using each action’s card/object identifier so the
action returns true only when that card is present in hand; preserve all
existing classifications.

Source: Path instructions

crates/engine/src/game/life_safety.rs-484-540 (1)

484-540: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Optional-cost arming revalidates against a pending shape the reducer never produces, and the mismatch fails open. begin_optional_additional_cost_attempt demands additional_cost_source == SpellCostSource::Other plus an unchanged flow, while handle_decide_repeatable_additional_cost sets neither and clones pending_before after the caller installed the flow. Any mismatch sets AttemptState::Invalid, records no receipt, and returns CandidateLifeSafety::NotUnsafe, which the AI reads as proven-safe.

  • crates/engine/src/game/life_safety.rs#L484-L540: derive the accepted pending shape from what each reducer path actually produces, or drop the additional_cost_source and flow-identity predicates and keep only the identity checks that the reducer guarantees on every path.
  • crates/engine/src/game/casting_costs.rs#L1040-L1050: pass a pending_before captured before the flow install and a pending_after whose fields satisfy the arming contract, and pass the prompt's inner Optional cost as materialized_cost if next_repeatable_additional_cost returns a different value.
🤖 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/life_safety.rs` around lines 484 - 540, The
optional-cost arming validation does not match the reducer’s actual
pending-state transitions and fails open as proven-safe. In
crates/engine/src/game/life_safety.rs:484-540, update
begin_optional_additional_cost_attempt validation to use reducer-produced
pending shapes, or remove the additional_cost_source and flow-identity
predicates while retaining reducer-guaranteed identity checks. In
crates/engine/src/game/casting_costs.rs:1040-1050, capture pending_before before
installing the flow, construct pending_after to satisfy the arming contract, and
pass the prompt’s inner Optional cost as materialized_cost when
next_repeatable_additional_cost differs.
crates/engine/src/ai_support/swarm.rs-46-75 (1)

46-75: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

binds_declaration never checks the incarnation, so the documented recycled-ID protection does not exist.

Line 47 states that the incarnation ref prevents a recycled object ID from inheriting a claim. binds_declaration receives only ObjectId values and compares attacker.object_id == *attacker_id. The incarnation counter stored in declaration is never compared, so a witness that is cached, stored, or redeemed after any zone change binds a different incarnation of the same ID. The current caller in crates/phase-ai/src/combat_ai.rs re-derives the witness from the same state, so the gap is latent today, but the guard cannot hold for a stored route (the search layer stores and redeems routes for other certificate kinds in this stack).

Bind against the live state so the incarnation is part of the check, or delete the claim from the doc comment.

🔒 Proposed identity-binding check
     pub fn binds_declaration(
         &self,
+        state: &GameState,
         attacks: &[(crate::types::identifiers::ObjectId, AttackTarget)],
     ) -> bool {
         self.declaration.len() == attacks.len()
             && self.declaration.iter().zip(attacks).all(
                 |((attacker, defending_player), (attacker_id, target))| {
                     attacker.object_id == *attacker_id
+                        && state
+                            .objects
+                            .get(attacker_id)
+                            .is_some_and(|object| {
+                                ObjectIncarnationRef::from_object(object) == *attacker
+                            })
                         && *defending_player == self.defending_player
                         && *target == AttackTarget::Player(*defending_player)
                 },
             )
     }
🤖 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/ai_support/swarm.rs` around lines 46 - 75, Update
SwarmCombatWitness::binds_declaration to validate each attacker's live
ObjectIncarnationRef, not only its ObjectId, by accepting the relevant state or
equivalent incarnation lookup and comparing it with the stored declaration
entry. Preserve the existing order, target, and length checks so recycled IDs
cannot satisfy a cached witness; alternatively remove the incarnation-protection
claim from the declaration documentation if identity validation cannot be added.
crates/phase-ai/src/combat_ai.rs-222-258 (1)

222-258: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Add a topology guard before adversarial_swarm_witness. The witness clones the state and explores blocker declarations, but it immediately returns UnsupportedTopology unless the game has exactly two non-team-based players. Guard those conditions before building the certification input. Use a damage precondition only with an engine-derived upper bound; twice the current power is not always sound when declaration-time effects change power or keywords.

🤖 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/phase-ai/src/combat_ai.rs` around lines 222 - 258, Add a topology
guard before constructing certified_candidates or calling
adversarial_swarm_witness, allowing certification only when the engine reports
exactly two non-team-based players; otherwise preserve the existing
non-certification flow. If adding a damage precondition, derive the upper bound
from the engine’s authoritative combat calculation and do not use twice the
current power, since declaration effects may change power or keywords.
crates/engine-wasm/src/lib.rs-2013-2017 (1)

2013-2017: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

This endpoint lost its SessionCache reuse.

score_candidates_for_parallel_worker builds its own session internally (AiSession::arc_from_game(state) in crates/phase-ai/src/search.rs). This call site previously supplied the cached session through ai_session_for. Each invocation now runs a full per-player deck analysis: DeckProfile, DeckFeatures, SynergyGraph, and the derived plan snapshot.

get_ai_scored_candidates is the parallel-worker scoring endpoint and is called once per determinized sample per worker, so the cost repeats on a hot path. crates/phase-ai/src/search.rs already treats that analysis as expensive enough to avoid duplicating.

Let the new function accept an optional caller-owned session, or keep using ai_session_for(state) here and apply the Pact guard separately.

🤖 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-wasm/src/lib.rs` around lines 2013 - 2017, Restore SessionCache
reuse in the get_ai_scored_candidates endpoint by passing the caller-owned
session from ai_session_for(state) into score_candidates_for_parallel_worker,
updating that function to accept an optional session if needed. Preserve the
existing parallel-worker scoring behavior while ensuring the per-player analysis
is not rebuilt for every determinized sample.
crates/phase-ai/src/planner/mod.rs-65-75 (1)

65-75: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

unwrap_or(0.0) is not neutral for an absolute state evaluation.

continuation_witness holds a state evaluation produced by services.evaluate_state, so its scale is the evaluator's, not a delta around zero. Both beam_priority and root_score add the raw witness and substitute 0.0 when no witness exists. Two consequences follow:

  • A candidate whose witness evaluates negative ranks below an otherwise identical candidate that has no witness. Beam truncation can then drop the reducer-proven route, which the doc comment states must not happen.
  • A candidate whose witness evaluates positive gains an unearned advantage over non-witnessed siblings, because the comparison baseline for those siblings is 0.0 rather than their own continuation value.

run_iterative_deepening already handles this correctly with cont.max(witness), which compares two values on the same scale. Apply the same treatment at the ranking seam: keep the witness as a comparison floor against a scale-matched baseline instead of an additive term over an implicit zero.

🤖 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/phase-ai/src/planner/mod.rs` around lines 65 - 75, Update
PlannerState::beam_priority and PlannerState::root_score to compare
continuation_witness with the existing continuation score using a scale-matched
maximum, following the cont.max(witness) behavior in run_iterative_deepening.
Remove the raw witness addition and unwrap_or(0.0) fallback while preserving
tactical weighting in root_score and ensuring a witness acts only as a
comparison floor.
crates/engine/src/ai_support/prospective_mana.rs-459-506 (1)

459-506: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bound the continuation branch of advance_pact_to_install.

The loop condition only counts priority_beats, and the waiting if waiting.acting_player() == Some(owner) arm never increments it. Each iteration of that arm applies one Target/Selection continuation without consuming any budget. If the reducer keeps exposing exactly one continuation candidate (for example a repeat-style prompt that re-enters the same waiting shape), the loop does not terminate, and the AI decision thread hangs inside certification.

The module already defines a decision-scoped allowance for this class of work. Consume one unit per continuation transition.

🔒 Proposed fix: charge continuation transitions against a bound
 fn advance_pact_to_install(
     state: &mut GameState,
     owner: PlayerId,
     source_id: ObjectId,
     journal_start: usize,
 ) -> Option<PactReceipt> {
     let mut priority_beats = 0;
-    while priority_beats < PROSPECTIVE_MAX_PRIORITY_BEATS {
+    let mut continuations_remaining = PROSPECTIVE_MAX_FORCED_TRANSITIONS;
+    while priority_beats < PROSPECTIVE_MAX_PRIORITY_BEATS {
@@
             waiting if waiting.acting_player() == Some(owner) => {
+                if continuations_remaining == 0 {
+                    return None;
+                }
+                continuations_remaining -= 1;
                 let mut continuations =
🤖 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/ai_support/prospective_mana.rs` around lines 459 - 506,
Update the continuation arm in advance_pact_to_install so each applied
Target/Selection continuation consumes one unit of the module’s existing
decision-scoped allowance, alongside the priority-beat bound. Ensure repeated
continuation prompts eventually terminate when that allowance is exhausted,
while preserving the current candidate validation and application behavior.
crates/engine/tests/integration/prospective_fetchland_mana.rs-1-23 (1)

1-23: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add the missing card-data fixture

prospective_fetchland_mana is registered, but neither referenced card-data.json path exists. full_card_db() will panic when the test runs. Add the fixture or update the path to an existing export.

🤖 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/prospective_fetchland_mana.rs` around lines 1
- 23, Fix full_card_db() so it loads a valid existing card-data export: either
add the missing card-data.json fixture at the path derived from
CARGO_MANIFEST_DIR or update the Path construction to an existing export
location, while preserving the CardDatabase::from_export initialization and test
behavior.

Source: Path instructions

crates/engine/src/types/game_state.rs-7919-7952 (1)

7919-7952: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The continuation detector misses continuations that serialize without trigger_context.

PendingContinuation::trigger_context uses #[serde(default, skip_serializing_if = "Option::is_none")] (lines 2097-2098). A continuation parked with no trigger context therefore serializes with chain but without trigger_context. The guard on line 7930 then never matches that frame, so its legacy firing_classification / dispatch_origin is neither converted nor removed. The doc comment on line 7921 states that a continuation always owns both keys; that is not true for the serialized form.

Two failure modes follow. A legacy delayed classification on such a frame is silently dropped. If the restored state also carries resolving_trigger_firing, validate_trigger_firing_coherence then rejects the state with "paused trigger continuation has no firing carrier for the active resolving trigger".

Detect the frame by chain alone, or by chain plus any of the firing keys.

🛠️ Proposed detector fix
-            if object.contains_key("chain") && object.contains_key("trigger_context") {
+            // `trigger_context` is skipped when absent, so `chain` plus a
+            // firing key is the only reliable continuation signature.
+            if object.contains_key("chain")
+                && (object.contains_key("trigger_context")
+                    || object.contains_key("trigger_firing")
+                    || object.contains_key("firing_classification")
+                    || object.contains_key("dispatch_origin"))
+            {
                 let canonical = object.get("trigger_firing").cloned();
🤖 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/types/game_state.rs` around lines 7919 - 7952, Update
migrate_legacy_continuation_firing to identify serialized continuation frames
using the presence of chain alone, rather than requiring trigger_context. Keep
the existing legacy firing_classification/dispatch_origin conversion, removal,
and canonical conflict validation unchanged so continuations without
trigger_context retain a valid trigger_firing carrier.
crates/engine/src/types/game_state.rs-8198-8226 (1)

8198-8226: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Two identical legacy delayed triggers make restore fail.

delayed_trigger_payload_matches compares the whole payload minus provenance. Two delayed triggers created by the same source with the same condition, ability, controller, and one_shot produce byte-identical payloads. That state is legitimate: two resolutions of the same effect install two identical delayed triggers. In a legacy save neither record carries provenance and both install commands stay unbound, so each record sees two candidates and hits the _ arm. The whole save then fails to load.

Bind identical candidates deterministically instead. Take the first unbound match and mark it bound; only report an error when a record has no candidate at all and the ambiguity is genuinely unresolvable (for example, when the number of records exceeds the number of matching commands).

🛠️ Sketch: deterministic first-match binding
-            match matches.as_slice() {
-                [index] => {
+            match matches.as_slice() {
+                // Identical payloads are indistinguishable by construction.
+                // Bind the lowest unbound command so the mapping is total and
+                // deterministic across peers rather than rejecting the save.
+                [index, ..] => {
                     let provenance = migrated_commands[*index]
                         .get("provenance")
                         .cloned()
                         .expect("migrated command always has private provenance");
                     delayed
                         .as_object_mut()
                         .expect("delayed trigger was validated as an object")
                         .insert("provenance".to_string(), provenance);
                     bound_commands.insert(*index);
                 }
                 [] => {}
-                _ => {
-                    return Err(
-                        "legacy delayed trigger matches multiple durable install commands"
-                            .to_string(),
-                    );
-                }
             }
🤖 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/types/game_state.rs` around lines 8198 - 8226, Update the
legacy delayed-trigger binding logic around delayed_trigger_payload_matches so
each record selects and binds the first unbound matching migrated command
deterministically instead of rejecting multiple matches. Track matching records
or otherwise ensure ambiguity is reported only when no candidate remains,
including when delayed records outnumber matching commands; preserve provenance
insertion and bound_commands updates for successful bindings.
crates/engine/src/types/resolution.rs-2482-2486 (1)

2482-2486: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Rebind legacy stack and resolving trigger carriers after provenance migration.

migrate_legacy_delayed_trigger_provenance updates install commands and live records, but not legacy carriers. A saved state whose delayed record left delayed_triggers can therefore fail validate_trigger_firing_coherence with “has no install root”. Rebind both carriers to the migrated provenance.

🤖 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/types/resolution.rs` around lines 2482 - 2486, Update the
resolution-state migration around migrate_legacy_delayed_trigger_provenance to
rebind both legacy stack and resolving trigger firing carriers after provenance
migration, before validate_trigger_firing_coherence runs. Ensure the carriers
reference the migrated install provenance so delayed records moved from
delayed_triggers retain a valid install root.
crates/engine/src/types/game_state.rs-5333-5358 (1)

5333-5358: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Replace the three integration-test DelayedTrigger literals. The private provenance field prevents these external-crate literals from compiling. Use DelayedTrigger::new(...) in crates/engine/tests/integration/lose_control_this_turn_delayed_trigger.rs, breeches_blastmaker_coin_flip_copy.rs, and mechtitan_core_return_exiled.rs.

🤖 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/types/game_state.rs` around lines 5333 - 5358, Replace the
three integration-test DelayedTrigger struct literals in
lose_control_this_turn_delayed_trigger.rs,
breeches_blastmaker_coin_flip_copy.rs, and mechtitan_core_return_exiled.rs with
DelayedTrigger::new(...), passing each literal’s existing condition, ability,
controller, source_id, and one_shot values; do not construct the private
provenance field externally.
crates/engine/src/types/resolved_commands.rs-1046-1049 (1)

1046-1049: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add a migration for legacy ResolvedRulesCommand::StackPush entries.

GameState persists the journal, but the trigger-carrier migration does not process stack-push commands. Serde supplies None for the missing field, and apply_resolved_stack_push rejects historical triggered entries with TriggerFiringShapeMismatch.

Migrate these commands or handle missing classifications without rejecting replay.

🤖 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/types/resolved_commands.rs` around lines 1046 - 1049,
Update the legacy deserialization/replay migration for
ResolvedRulesCommand::StackPush so entries missing trigger_firing are handled
compatibly instead of being rejected by apply_resolved_stack_push with
TriggerFiringShapeMismatch. Ensure the migration derives and attaches the
appropriate TriggerFiring classification for historical triggered stack pushes,
while preserving None for genuinely untriggered commands.
crates/engine/src/types/game_state.rs-19173-19177 (1)

19173-19177: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Include stack_trigger_firings in loop-state equality, or document and test its exclusion.

normalize_for_loop retains this map, and TriggerFiring::Delayed differs from TriggerFiring::Ordinary. States with identical stack entries but different firing classifications therefore compare equal through loop_states_equal, which can cause a false CR 104.4b repeat and affect equality-based AI deduplication.

🤖 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/types/game_state.rs` around lines 19173 - 19177, Update the
loop-state comparison around normalize_for_loop and loop_states_equal to include
stack_trigger_firings in equality, preserving the distinction between
TriggerFiring::Delayed and TriggerFiring::Ordinary. If exclusion is intentional
instead, document it and add tests covering differing firing classifications;
otherwise ensure such states no longer compare equal.
crates/engine/src/game/triggers.rs-7245-7267 (1)

7245-7267: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

The full GameState clone runs on every targeted trigger dispatch.

prepared_state = state.clone() executes before the code determines whether any mutation is required. auto_select_targets_for_ability takes &prepared_state and assign_targets_in_chain takes &prepared_state, so only the TargetSelectionMode::Random path needs a mutable state. Trigger dispatch is a hot path — a board with many simultaneous triggers clones the whole state once per trigger.

Clone only for the random branch, and keep the deterministic branch on state.

♻️ Restrict the clone to the RNG-advancing branch
-    let mut prepared_state = state.clone();
-    let mut prepared_trigger = trigger.clone();
-    let auto_targets = if matches!(
-        prepared_trigger.ability.target_selection_mode,
-        crate::types::ability::TargetSelectionMode::Random
-    ) {
-        super::ability_utils::random_select_targets_for_ability(
-            &mut prepared_state,
-            &target_slots,
-            &prepared_trigger.target_constraints,
-        )
-        .map(Some)
-    } else {
-        super::ability_utils::auto_select_targets_for_ability(
-            &prepared_state,
-            &prepared_trigger.ability,
-            &target_slots,
-            &prepared_trigger.target_constraints,
-        )
-    };
+    let mut prepared_trigger = trigger.clone();
+    let random_selection = matches!(
+        prepared_trigger.ability.target_selection_mode,
+        crate::types::ability::TargetSelectionMode::Random
+    );
+    // Only random selection advances the engine RNG, so only it needs a
+    // throwaway state.
+    let mut prepared_state = if random_selection {
+        state.clone()
+    } else {
+        // Borrow the live state read-only for the deterministic path.
+        state.clone_shallow_for_target_preparation()
+    };

An alternative is to keep state for the deterministic path and introduce a small PreparedRng carrier that only the random path commits.

🤖 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/triggers.rs` around lines 7245 - 7267, Move the
prepared_state clone out of the common trigger-dispatch path and create it only
inside the TargetSelectionMode::Random branch before
random_select_targets_for_ability. Keep auto_select_targets_for_ability and
subsequent deterministic target assignment using the original state reference,
while preserving the random branch’s transactional state handling.
crates/engine/src/game/triggers.rs-315-320 (1)

315-320: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

#[serde(default)] on firing drops delayed identity for previously serialized contexts.

dispatch_origin is retained for wire compatibility, but firing defaults independently. A payload written before this change contains dispatch_origin: "Delayed" and no firing field. After deserialization the context becomes TriggerFiring::Ordinary. Two behaviors then change for that context: apply_trigger_doubling doubles it (line 7619), and dispatch_pending_trigger_context_with_origin stops applying the DroppedTargetUnresolvedPushed fallback (line 7012).

Derive firing from dispatch_origin when the field is absent.

🛡️ Derive the default from the legacy field
-    #[serde(default)]
+    #[serde(default = "TriggerFiring::default")]
     pub(crate) firing: TriggerFiring,

Then add a #[serde(deserialize_with = ...)] or a post-deserialize normalization that maps dispatch_origin == Delayed with a defaulted firing to TriggerFiring::Delayed(None).

🤖 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/triggers.rs` around lines 315 - 320, Update
deserialization for the scheduler carrier containing `firing` so legacy payloads
without that field derive `TriggerFiring::Delayed(None)` when `dispatch_origin`
is `Delayed`, while preserving explicit `firing` values and ordinary behavior
for other origins. Use a serde deserializer or post-deserialization
normalization near the `firing` field and retain compatibility with existing
`PendingTrigger` literals.
crates/engine/src/game/triggers.rs-5795-5795 (1)

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

Delayed triggers can no longer auto-order.

TriggerFiring::Delayed(Some(provenance)) carries the unique token and instance, so two delayed triggers never compare equal. The previous dispatch_origin comparison treated two delayed firings as equal. With this change, group_is_order_independent returns false for every group that contains two delayed triggers, and CR 603.3b auto-ordering degrades into a mandatory OrderTriggers prompt.

A concrete case: two Dash permanents create identical "return this permanent to its owner's hand at the beginning of the next end step" delayed triggers. Both are no-input triggers with byte-identical normalized abilities, so they must auto-order. They now prompt.

Compare the dispatch class, not the individual identity.

♻️ Compare only the ordinary-versus-delayed class
-        ctx.firing() == first.firing()
+        ctx.firing().is_delayed() == first.firing().is_delayed()
🤖 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/triggers.rs` at line 5795, Update the comparison in
group_is_order_independent to compare each firing’s dispatch class—ordinary
versus delayed—instead of full TriggerFiring identity or dispatch_origin.
Preserve auto-ordering for groups containing equivalent delayed triggers while
still distinguishing ordinary and delayed firings.
crates/engine/src/game/triggers.rs-893-947 (1)

893-947: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

The duplicate-provenance scan is quadratic in installs per game.

Each install walks the whole resolved_rules_journal twice and the whole delayed_triggers vector twice. The journal grows monotonically, so total cost grows as O(n²) in the number of delayed-trigger installs. Delayed triggers are created by common keywords (Dash, Blitz, Decayed, end-step sacrifice riders), so a long game accumulates many entries.

Track the issued tokens and instances in two sets on GameState, or perform a single journal pass that checks both keys.

♻️ Single journal pass for both keys
-    let journal_has = |matches: &dyn Fn(DelayedTriggerProvenance) -> bool| {
-        state
-            .resolved_rules_journal
-            .entries()
-            .iter()
-            .filter_map(|entry| entry.command.as_ref())
-            .filter_map(|command| match command {
-                crate::types::resolved_commands::ResolvedRulesCommand::DelayedTriggerInstall(
-                    command,
-                ) => command.trigger.provenance,
-                _ => None,
-            })
-            .any(matches)
-    };
-    if journal_has(&|installed| installed.token == provenance.token) {
-        return Err(
-            ResolvedDelayedTriggerReplayInvariantError::DuplicateProvenanceToken {
-                token: provenance.token,
-            },
-        );
-    }
-    if journal_has(&|installed| installed.instance == provenance.instance) {
-        return Err(
-            ResolvedDelayedTriggerReplayInvariantError::DuplicateProvenanceInstance {
-                instance: provenance.instance,
-            },
-        );
-    }
+    let installed_provenances = state
+        .resolved_rules_journal
+        .entries()
+        .iter()
+        .filter_map(|entry| entry.command.as_ref())
+        .filter_map(|command| match command {
+            crate::types::resolved_commands::ResolvedRulesCommand::DelayedTriggerInstall(
+                command,
+            ) => command.trigger.provenance,
+            _ => None,
+        })
+        .chain(state.delayed_triggers.iter().filter_map(|trigger| trigger.provenance));
+    for installed in installed_provenances {
+        if installed.token == provenance.token {
+            return Err(
+                ResolvedDelayedTriggerReplayInvariantError::DuplicateProvenanceToken {
+                    token: provenance.token,
+                },
+            );
+        }
+        if installed.instance == provenance.instance {
+            return Err(
+                ResolvedDelayedTriggerReplayInvariantError::DuplicateProvenanceInstance {
+                    instance: provenance.instance,
+                },
+            );
+        }
+    }

This also removes the two separate delayed_triggers scans below.

🤖 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/triggers.rs` around lines 893 - 947, Optimize the
duplicate-provenance validation around the replay/install logic by scanning the
resolved-rules journal and active delayed_triggers once each, checking both
token and instance in those passes. Preserve the existing
DuplicateProvenanceToken and DuplicateProvenanceInstance errors, and avoid
repeating full-collection scans for each key.
crates/engine/src/game/triggers.rs-821-825 (1)

821-825: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix the remaining install_delayed_trigger call sites.

Five callers still pass only two arguments: crates/engine/src/game/effects/{rebound,counters,token,myriad}.rs. Pass events or remove the unused parameter. blitz.rs and dash.rs already pass three arguments.

🤖 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/triggers.rs` around lines 821 - 825, Update all
remaining install_delayed_trigger call sites in rebound.rs, counters.rs,
token.rs, and myriad.rs to match the three-argument signature by passing the
available events collection, or remove the unused _events parameter from
install_delayed_trigger and adjust every caller consistently; preserve the
already-correct blitz.rs and dash.rs usage.
crates/engine/src/game/stack.rs-41-60 (1)

41-60: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve delayed-trigger provenance across copy and batch resolution.

  • push_copy_to_stack records every copied TriggeredAbility as TriggerFiring::Ordinary, although CopySpell can copy any StackAbility, including delayed triggers. Propagate the source entry’s firing classification.
  • resolve_batched discards each popped entry’s firing classification before plan.execute. A batch of delayed Effect::Token triggers therefore clears resolving_trigger_firing, causing the Pact tracker to report the obligation as expired. Preserve the batch firing during execution.
🤖 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/stack.rs` around lines 41 - 60, Preserve TriggerFiring
provenance for delayed triggers in both copy and batch resolution. Update
push_copy_to_stack to reuse the source StackEntry’s firing classification when
copying TriggeredAbility entries instead of forcing Ordinary. Update
resolve_batched to retain each popped entry’s firing value through plan.execute
so delayed Effect::Token triggers keep resolving_trigger_firing available to the
Pact tracker.
🟡 Minor comments (7)
crates/engine/src/game/casting.rs-10363-10366 (1)

10363-10366: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use object.owner for hand-zone authentication.

move_to_zone preserves controller, so a permanent returned to its owner’s hand can retain controller != owner. This guard can then hide the owner’s legal Evoke choice while normal casting remains available. Compare object.owner for Zone::Hand (CR 404.2), or reset controller when the object leaves the battlefield.

🤖 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/casting.rs` around lines 10363 - 10366, Update the
hand-zone authentication guard in the relevant casting function to compare the
object’s owner with player instead of its controller when validating Zone::Hand
cards, while retaining the card_id and zone checks.

Source: Path instructions

crates/engine/src/ai_support/evoke.rs-56-59 (1)

56-59: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace CR 115.1d with citations for target legality and opponent identification. CR 115.1d only defines when a triggered ability is targeted. It does not define legal target enumeration or opponent identification. Use CR 115.2 and the applicable opponent rule.

🤖 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/ai_support/evoke.rs` around lines 56 - 59, Update the
documentation comment above the Evoke handling to replace CR 115.1d with CR
115.2 for target legality and add the applicable opponent-identification rule
citation, while preserving the existing CR 603.6a and Evoke behavior
descriptions.

Source: Path instructions

crates/engine/src/game/derived_views.rs-2755-2797 (1)

2755-2797: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add a deserialize assertion for the redacted payload.

The test proves the private fields are absent. It does not prove the client can still consume the payload. client_state_wire_value removes keys from a serialized GameState, so ClientGameState deserialization now depends on every removed field having a serde default. No other test in this module populates those fields, so a missing default would fail only in real games that carry delayed triggers.

Assert the round-trip inside the same loop.

💚 Proposed test addition
         for client_state in [&client["state"], &filtered_client["state"]] {
+            serde_json::from_value::<crate::types::game_state::GameState>(client_state.clone())
+                .expect("redacted client state must still deserialize");
             for private_field in [
🤖 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/derived_views.rs` around lines 2755 - 2797, Within the
loop over client and filtered client state in the relevant test, deserialize
each redacted client_state using the existing ClientGameState type and assert
that it succeeds. Place this round-trip assertion after client_state_wire_value
removes private fields, preserving the existing omission checks while verifying
all removed fields have serde defaults.
crates/engine/src/game/life_safety.rs-119-134 (1)

119-134: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Reuse the generated candidate set during life-safety validation.

is_complete_generated_candidate regenerates all candidates for each preview and again at the cost authority. candidate_actions does not call life_safety, so recursion is not present. Pass the validated candidate provenance through the probe and reuse it instead of repeating full enumeration on the AI path.

🤖 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/life_safety.rs` around lines 119 - 134, Update the
life-safety probe flow around is_complete_generated_candidate to accept the
already validated generated-candidate provenance and reuse it during validation.
Remove the repeated crate::ai_support::candidate_actions enumeration from this
function, while preserving the semantic_owner, actor, action, and tactical_class
matching checks against the passed candidate set. Ensure the same provenance is
forwarded through the preview and cost-authority paths.

Source: Path instructions

crates/engine/src/ai_support/swarm.rs-296-304 (1)

296-304: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

A missing object silently shortens worst_declaration instead of abstaining.

The filter_map with ? drops any pair whose blocker or attacker is not found in after_attack.objects. The certificate then reports a block declaration that is shorter than the one the reducer replayed. Every other lookup failure in this file returns Indeterminate. Keep that fail-closed shape here.

🛡️ Proposed fail-closed collection
-            let bound_declaration = declaration
+            let Some(bound_declaration) = declaration
                 .iter()
-                .filter_map(|(blocker, attacker)| {
+                .map(|(blocker, attacker)| {
                     Some((
                         ObjectIncarnationRef::from_object(after_attack.objects.get(blocker)?),
                         ObjectIncarnationRef::from_object(after_attack.objects.get(attacker)?),
                     ))
                 })
-                .collect();
+                .collect::<Option<Vec<_>>>()
+            else {
+                failure = Some(SwarmWitnessIndeterminate::InvalidAttack);
+                return ControlFlow::Break(());
+            };
🤖 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/ai_support/swarm.rs` around lines 296 - 304, Update the
collection building worst_declaration so a missing blocker or attacker does not
get silently filtered out. Replace the filter_map/? path with fail-closed
handling that returns Indeterminate when any lookup in after_attack.objects
fails, while preserving the complete declaration when all ObjectIncarnationRef
conversions succeed.
crates/engine/tests/integration/swarm_combat_witness.rs-217-223 (1)

217-223: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

No test reaches the empty-attacks guard.

This sub-case passes an empty attack list, but swarm_witness_inner returns InvalidAttack at the WaitingFor::DeclareAttackers check first, because the fixture stays in PreCombatMain. The attacks.is_empty() condition in crates/engine/src/ai_support/swarm.rs line 131 is never exercised. Add a case that advances to combat and then passes &[], so the guard has a reach-proving test.

As per path instructions: "For every negative assertion ... require a paired positive reach-guard proving the input actually reached the code under test."

💚 Proposed added reach-guard case
     let mut precombat = GameScenario::new();
     precombat.at_phase(Phase::PreCombatMain);
     let precombat = precombat.build();
     assert_eq!(
         adversarial_swarm_witness(precombat.state(), P0, &[]),
         SwarmWitnessResult::Indeterminate(SwarmWitnessIndeterminate::InvalidAttack)
     );
+
+    // Reach guard: in the declare-attackers step, an empty declaration is
+    // rejected by the `attacks.is_empty()` branch, not by the step check.
+    let mut empty_declaration = GameScenario::new();
+    empty_declaration.at_phase(Phase::PreCombatMain);
+    empty_declaration.add_creature(P0, "Bear", 3, 3);
+    let mut empty_declaration = empty_declaration.build();
+    empty_declaration.advance_to_combat();
+    assert!(matches!(
+        empty_declaration.state().waiting_for,
+        engine::types::game_state::WaitingFor::DeclareAttackers { .. }
+    ));
+    assert_eq!(
+        adversarial_swarm_witness(empty_declaration.state(), P0, &[]),
+        SwarmWitnessResult::Indeterminate(SwarmWitnessIndeterminate::InvalidAttack)
+    );
🤖 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/swarm_combat_witness.rs` around lines 217 -
223, Update the test around adversarial_swarm_witness to add a paired case that
advances the scenario into combat, reaches the DeclareAttackers state, and
passes an empty attack slice so swarm_witness_inner evaluates its
attacks.is_empty() guard. Keep the existing PreCombatMain InvalidAttack
assertion, and assert the combat-phase case produces the guard’s expected
result.

Source: Path instructions

crates/engine/src/ai_support/swarm.rs-191-196 (1)

191-196: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the combat-damage rule citation and claim. CR 614.1a defines replacement effects; it does not establish a combat-damage event before blockers are declared. Cite CR 510.1 for combat-damage assignment and retain CR 614.1a only for the replacement-effect rationale. Update the repeated citations in crates/engine/tests/integration/swarm_combat_witness.rs.

🤖 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/ai_support/swarm.rs` around lines 191 - 196, The comment
above the early return in the combat-damage replacement check incorrectly
attributes event timing to CR 614.1a; cite CR 510.1 for combat-damage assignment
and retain CR 614.1a only to explain the replacement-effect rationale. Apply the
same citation and claim correction to the repeated comments in the swarm combat
witness integration tests.

Source: Learnings

@matthewevans
matthewevans force-pushed the ship/ai-reliability-fixes branch from cbb5967 to 74a6f4c Compare July 31, 2026 22:11
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans
matthewevans force-pushed the ship/ai-reliability-fixes branch from 74a6f4c to d8eb0ad Compare July 31, 2026 22:34

@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: 10

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/types/game_state.rs (1)

18392-18446: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Canonicalize the trigger-firing carriers in normalize_for_loop.

This function already zeroes both delayed-trigger allocators and clears provenance from every live delayed trigger, because a per-iteration identity would defeat CR 104.4b comparison. The new firing carriers keep that identity:

  • pending_trigger_firing holds TriggerFiring::Delayed(Some(provenance)) and is compared in impl PartialEq for GameState (Line 19302).
  • stack_trigger_firings holds the same payload and is compared in loop_states_equal (Line 18839).

A loop that re-installs a delayed trigger each iteration mints a new token and instance, so those carriers differ between iterations. The two normalized states then never compare equal, and the mandatory-loop draw is missed. This is the same failure mode the file documents for resolution_source_relatch.

Map Delayed(Some(_)) to Delayed(None) for the pending, stack, resolving, and parked-continuation carriers while normalizing.

🛠️ Proposed fix sketch
         for dt in clone.delayed_triggers.iter_mut() {
             dt.ability.clear_trigger_identity_recursive();
             dt.provenance = None;
         }
+        // CR 104.4b: a per-iteration delayed-trigger identity must not enter
+        // loop equality through a firing carrier either.
+        let canonicalize = |firing: &mut TriggerFiring| {
+            if matches!(firing, TriggerFiring::Delayed(Some(_))) {
+                *firing = TriggerFiring::Delayed(None);
+            }
+        };
+        if let Some(firing) = clone.pending_trigger_firing.as_mut() {
+            canonicalize(firing);
+        }
+        if let Some(firing) = clone.resolving_trigger_firing.as_mut() {
+            canonicalize(firing);
+        }
+        for firing in clone.stack_trigger_firings.values_mut() {
+            canonicalize(firing);
+        }
🤖 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/types/game_state.rs` around lines 18392 - 18446, Update
normalize_for_loop to canonicalize delayed-trigger provenance in all
trigger-firing carriers: pending_trigger_firing, stack_trigger_firings,
resolving, and parked-continuation state. Traverse each carrier and convert
TriggerFiring::Delayed(Some(_)) to TriggerFiring::Delayed(None), preserving
other TriggerFiring variants and existing normalization behavior so loop
comparisons ignore per-iteration delayed-trigger identity.
🧹 Nitpick comments (6)
crates/engine/src/ai_support/swarm.rs (1)

240-242: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Bound the reducer work, not only the declaration count.

checked_declaration_product permits up to SWARM_WITNESS_MAX_DECLARATIONS (4096) leaves. Each legal leaf clones the whole GameState at line 268 and then drives the reducer through damage completion. combat_ai::choose_attackers_with_targets_with_profile calls this witness on every attack decision, so a wide board can cost thousands of full state clones plus reducer runs per decision.

Two low-risk mitigations: add an early exit when life_loss == 0 (no defense can do better for the attacker), and cap the number of executed candidate branches separately from the enumeration cap so the worst case is a fixed reducer budget.

♻️ Proposed early exit
             if worst
                 .as_ref()
                 .is_none_or(|(least_loss, _)| life_loss < *least_loss)
             {
                 worst = Some((life_loss, bound_declaration));
             }
+            if matches!(worst.as_ref(), Some((0, _))) {
+                // Zero life loss is the minimum; no other declaration can beat it.
+                return ControlFlow::Break(());
+            }
             ControlFlow::Continue(())

Note: an early Break must not be confused with the failure path; failure stays None here, so the existing if let Some(reason) = failure check still passes.

Also applies to: 268-268

🤖 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/ai_support/swarm.rs` around lines 240 - 242, Update the
witness reducer loop around checked_declaration_product so it exits immediately
when life_loss reaches zero, preserving failure as None so normal success
handling remains unchanged. Add a separate fixed cap on executed candidate
branches, distinct from SWARM_WITNESS_MAX_DECLARATIONS, and stop processing
further leaves once that reducer budget is reached; apply the cap at the point
where each candidate clones GameState and runs the reducer.
crates/phase-ai/src/policies/mulligan/mod.rs (1)

100-106: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Model the probe outcome with a typed shape instead of a probed flag plus three witness bools.

probed encodes "no result" in the same struct as the results, so Self::default() is both "not probed" and "no witnesses found". A caller that forgets probed reads an unprobed hand as dead. Return Option<OpeningHandActionForecast> (or a two-variant enum Inconclusive / Probed { .. }) and keep only the witness fields inside the probed variant.

Based on path instructions: "any new bool struct field or bool variant payload where a typed enum ... would carry the same information with more meaning".

Also applies to: 173-178

🤖 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/phase-ai/src/policies/mulligan/mod.rs` around lines 100 - 106, Replace
OpeningHandActionForecast’s probed flag plus witness booleans with an explicit
optional or two-variant typed result, keeping only the witness fields in the
probed value. Update the probe-producing and consuming logic around
OpeningHandActionForecast so an unprobed hand is represented as
absent/inconclusive and cannot be interpreted as a probed result with all
witnesses false.

Source: Path instructions

crates/engine/src/types/game_state.rs (4)

18838-18840: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document or align where stack_trigger_firings is compared.

loop_states_equal now compares stack_trigger_firings, but impl PartialEq for GameState does not. stack itself is compared in PartialEq. Two states that differ only in one stack entry's firing classification therefore compare equal for AI-search dedup and for every other == consumer, while loop_states_equal treats them as distinct.

Either add the field to the PartialEq chain beside pending_trigger_firing, or state in the field doc why the comparison belongs only to loop identity.

🤖 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/types/game_state.rs` around lines 18838 - 18840, Align
GameState equality with loop_states_equal by adding stack_trigger_firings to the
PartialEq comparison chain alongside pending_trigger_firing and the existing
stack comparison. Ensure states differing only in firing classification compare
as distinct for all == consumers.

8203-8224: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid the per-pair deep clone in the legacy binding scan.

delayed_trigger_payload_matches clones both JSON values on every comparison, and this loop compares each provenance-less live delayed trigger against every migrated install command. Each clone copies a full resolved-ability tree. The cost is live × commands deep clones on the restore path.

Strip provenance once per command trigger and once per live record before the scan, then compare the stripped values.

🤖 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/types/game_state.rs` around lines 8203 - 8224, Update the
legacy binding scan around delayed_trigger_payload_matches to avoid cloning JSON
values for every live-trigger/command pair. Before the nested comparisons,
create provenance-stripped payloads once for each migrated command trigger and
each live delayed record, then compare those stripped values while preserving
the existing bound_commands selection and provenance insertion behavior.

7541-7565: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Replace the linear reuse scans with dedicated index sets.

register_root scans roots.keys() twice for every registration. The function registers one root per DelayedTriggerInstall journal entry plus one per live delayed trigger, so restore cost is quadratic in the number of delayed installations recorded for the game. The journal is append-only, so a long game pays this on every restore.

Track tokens and instances in separate BTreeSet<u64> values and query them directly.

♻️ Proposed refactor sketch
-    let mut roots = BTreeMap::<(u64, u64), ObjectId>::new();
+    let mut roots = BTreeMap::<(u64, u64), ObjectId>::new();
+    let mut used_tokens = BTreeSet::<u64>::new();
+    let mut used_instances = BTreeSet::<u64>::new();
@@
-                if roots.keys().any(|(token, _)| *token == provenance.token.0) {
+                if used_tokens.contains(&provenance.token.0) {
                     return Err(format!(
                         "{carrier} delayed-trigger provenance reuses a token"
                     ));
                 }
-                if roots
-                    .keys()
-                    .any(|(_, instance)| *instance == provenance.instance.0)
-                {
+                if used_instances.contains(&provenance.instance.0) {
                     return Err(format!(
                         "{carrier} delayed-trigger provenance reuses an instance"
                     ));
                 }
                 roots.insert(key, source_id);
+                used_tokens.insert(provenance.token.0);
+                used_instances.insert(provenance.instance.0);
🤖 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/types/game_state.rs` around lines 7541 - 7565, Update the
register_root logic to maintain dedicated BTreeSet<u64> indexes for provenance
tokens and instances alongside roots. Replace both roots.keys().any(...) reuse
scans with direct set membership checks, and insert each token and instance into
the sets only after validation succeeds; preserve existing alias and error
behavior.

20044-20064: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a failure-path test for the allocator bound checks.

validate_trigger_firing_coherence rejects a state whose next_delayed_trigger_token or next_delayed_trigger_instance is not above every install root (Lines 7732-7739). The new tests cover migration normalization of those allocators, but no test drives the validator's rejection. A regression that drops either bound check would stay green.

Add a case that installs one delayed trigger, lowers each allocator to the root value, and asserts the restore error text for both fields.

As per path instructions: "A test must exercise the FAILURE path the fix prevents".

🤖 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/types/game_state.rs` around lines 20044 - 20064, The
migration tests lack coverage for validator rejection when delayed-trigger
allocators are not above an install root. Extend the relevant delayed-trigger
test coverage around validate_trigger_firing_coherence to install one delayed
trigger, set next_delayed_trigger_token and next_delayed_trigger_instance to the
root value in separate cases, and assert each restore failure contains the
corresponding field’s error text.

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/ai_support/swarm.rs`:
- Around line 291-297: Clamp the computed life loss to zero before converting it
to u32 in the damage-resolution flow around advance_to_damage_completion. Update
the life_loss calculation using the existing before/after life values so
defender life gains produce zero loss, while actual losses retain their correct
magnitude and downstream lethal checks remain valid.

In `@crates/phase-ai/src/combat_ai.rs`:
- Around line 3637-3640: Update both abstention tests around
adversarial_swarm_witness to match the specific
SwarmWitnessIndeterminate::DamageChoice reason inside
SwarmWitnessResult::Indeterminate, using the re-export from engine::ai_support.
Preserve the existing assertions that the result is indeterminate while
requiring DamageChoice at both test locations.

In `@crates/phase-ai/src/policies/mulligan/mod.rs`:
- Around line 181-197: Update is_normal_opening_hand_action to match every
GameAction variant explicitly, preserving hand.contains checks for the listed
hand-object actions and returning false through named arms for all
non-opening-action variants. Remove the wildcard fallback so adding a new
GameAction variant causes a compiler error until it is classified.
- Around line 122-164: The mulligan forecast loop around legal_actions_full
currently probes equivalent unadvanced state clones twice. Either advance the
cloned game state through the engine between first_turn and first_turn +
player_count so the second window reflects turn progression and resources, or
reduce the logic to one probe and update its comment to describe a single-window
model; keep the forecast flags consistent with the chosen behavior.
- Around line 891-916: Strengthen
forecast_rejects_unaffordable_or_targetless_opening_actions with positive
reach-guards for both fixtures. For the activation case, change only the
rejected affordability condition by providing sufficient mana or using
AbilityCost::Tap, then assert is_certified_dead_landless() is false; for the
spell case, add a battlefield creature while keeping the spell otherwise
unchanged, then assert the forecast is not certified dead. Keep each control
paired with its existing negative assertion to verify the code reaches legal
action evaluation.
- Around line 109-115: Update `Mulligan::for_hand` to attribute hand objects
using each object's `owner`, never `controller`, when selecting the player for
the forecast. Ensure the hand is validated as belonging to one consistent owner
rather than binding to an arbitrary object from a mixed-ownership slice;
preserve the default result when no valid owner can be established.
- Around line 118-121: Guard the zero-player case in the function containing the
first_turn calculation before evaluating the modulo, returning the default
forecast for an empty player list. Preserve the existing calculation and
WaitingFor::Priority behavior when player_count is nonzero, and continue using
the GameObject controller field as required by the probe.

In `@crates/phase-ai/src/policies/self_cost.rs`:
- Around line 555-573: Replace the boolean
residual_effects_are_trivial_at_x_zero result with a three-state ResidualVerdict
that distinguishes TrivialAtXZero, MeaningfulAtXZero, and Unknown. Map
classify_effects results accordingly, treating Unmodeled as Unknown rather than
trivial, and update x_cast_gate to abstain whenever the residual verdict is
Unknown.
- Around line 471-480: Update effect_benefit_value’s AI-only Effect::Draw
handling so chained draws are not independently previewed against the unchanged
base state. Prefer previewing the complete chain against evolving state, or
conservatively return None when more than one AI-only draw exists; ensure
pricing does not count undeliverable duplicate previews.
- Around line 714-720: Update the opponent-only Draw arm in the Effect
triviality handling to credit remaining_opponent_draw_churn only when the draw
count expression is mandatory, checking count.is_up_to() before resolving and
adding the count. Preserve the existing scalar budget semantics and verify that
resolve_quantity supplies a per-player count rather than an aggregate; adjust
only if needed to prevent over-crediting across multiple opponents.

---

Outside diff comments:
In `@crates/engine/src/types/game_state.rs`:
- Around line 18392-18446: Update normalize_for_loop to canonicalize
delayed-trigger provenance in all trigger-firing carriers:
pending_trigger_firing, stack_trigger_firings, resolving, and
parked-continuation state. Traverse each carrier and convert
TriggerFiring::Delayed(Some(_)) to TriggerFiring::Delayed(None), preserving
other TriggerFiring variants and existing normalization behavior so loop
comparisons ignore per-iteration delayed-trigger identity.

---

Nitpick comments:
In `@crates/engine/src/ai_support/swarm.rs`:
- Around line 240-242: Update the witness reducer loop around
checked_declaration_product so it exits immediately when life_loss reaches zero,
preserving failure as None so normal success handling remains unchanged. Add a
separate fixed cap on executed candidate branches, distinct from
SWARM_WITNESS_MAX_DECLARATIONS, and stop processing further leaves once that
reducer budget is reached; apply the cap at the point where each candidate
clones GameState and runs the reducer.

In `@crates/engine/src/types/game_state.rs`:
- Around line 18838-18840: Align GameState equality with loop_states_equal by
adding stack_trigger_firings to the PartialEq comparison chain alongside
pending_trigger_firing and the existing stack comparison. Ensure states
differing only in firing classification compare as distinct for all ==
consumers.
- Around line 8203-8224: Update the legacy binding scan around
delayed_trigger_payload_matches to avoid cloning JSON values for every
live-trigger/command pair. Before the nested comparisons, create
provenance-stripped payloads once for each migrated command trigger and each
live delayed record, then compare those stripped values while preserving the
existing bound_commands selection and provenance insertion behavior.
- Around line 7541-7565: Update the register_root logic to maintain dedicated
BTreeSet<u64> indexes for provenance tokens and instances alongside roots.
Replace both roots.keys().any(...) reuse scans with direct set membership
checks, and insert each token and instance into the sets only after validation
succeeds; preserve existing alias and error behavior.
- Around line 20044-20064: The migration tests lack coverage for validator
rejection when delayed-trigger allocators are not above an install root. Extend
the relevant delayed-trigger test coverage around
validate_trigger_firing_coherence to install one delayed trigger, set
next_delayed_trigger_token and next_delayed_trigger_instance to the root value
in separate cases, and assert each restore failure contains the corresponding
field’s error text.

In `@crates/phase-ai/src/policies/mulligan/mod.rs`:
- Around line 100-106: Replace OpeningHandActionForecast’s probed flag plus
witness booleans with an explicit optional or two-variant typed result, keeping
only the witness fields in the probed value. Update the probe-producing and
consuming logic around OpeningHandActionForecast so an unprobed hand is
represented as absent/inconclusive and cannot be interpreted as a probed result
with all witnesses false.
🪄 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: 6a0123bd-236a-4300-b5c1-b116d7eada8c

📥 Commits

Reviewing files that changed from the base of the PR and between e63b047 and 74a6f4c.

📒 Files selected for processing (72)
  • crates/engine-wasm/src/lib.rs
  • crates/engine/src/ai_support/evoke.rs
  • crates/engine/src/ai_support/mod.rs
  • crates/engine/src/ai_support/prospective_mana.rs
  • crates/engine/src/ai_support/swarm.rs
  • crates/engine/src/game/archenemy_tests.rs
  • crates/engine/src/game/blitz.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/game/cipher.rs
  • crates/engine/src/game/dash.rs
  • crates/engine/src/game/derived_views.rs
  • crates/engine/src/game/effects/cast_copy_of_card.rs
  • crates/engine/src/game/effects/copy_spell.rs
  • crates/engine/src/game/effects/counters.rs
  • crates/engine/src/game/effects/delayed_trigger.rs
  • crates/engine/src/game/effects/draw.rs
  • crates/engine/src/game/effects/encore.rs
  • crates/engine/src/game/effects/end_phase.rs
  • crates/engine/src/game/effects/epic.rs
  • crates/engine/src/game/effects/exile_resolving_spell.rs
  • crates/engine/src/game/effects/life.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/myriad.rs
  • crates/engine/src/game/effects/paradigm.rs
  • crates/engine/src/game/effects/phase_out.rs
  • crates/engine/src/game/effects/rebound.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/elimination.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_exile_return_tests.rs
  • crates/engine/src/game/engine_keyword_action_stack_tests.rs
  • crates/engine/src/game/engine_modes.rs
  • crates/engine/src/game/engine_phase_trigger_regression_tests.rs
  • crates/engine/src/game/engine_priority.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/src/game/life_safety.rs
  • crates/engine/src/game/mod.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/static_abilities.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/triggers_pr7_order_template_tests.rs
  • crates/engine/src/game/visibility.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/identifiers.rs
  • crates/engine/src/types/resolution.rs
  • crates/engine/src/types/resolved_commands.rs
  • crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs
  • crates/engine/tests/integration/cr733_resolved_modifier_install.rs
  • crates/engine/tests/integration/cr733_resolved_trigger_collection.rs
  • crates/engine/tests/integration/draw_delivery_preview.rs
  • crates/engine/tests/integration/lose_control_this_turn_delayed_trigger.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/mechtitan_core_return_exiled.rs
  • crates/engine/tests/integration/pact_of_negation_upkeep_payment.rs
  • crates/engine/tests/integration/prospective_fetchland_mana.rs
  • crates/engine/tests/integration/swarm_combat_witness.rs
  • crates/phase-ai/src/combat_ai.rs
  • crates/phase-ai/src/lib.rs
  • crates/phase-ai/src/planner/mod.rs
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/effect_classify.rs
  • crates/phase-ai/src/policies/mulligan/card_floor.rs
  • crates/phase-ai/src/policies/mulligan/keepables_by_land_count.rs
  • crates/phase-ai/src/policies/mulligan/mod.rs
  • crates/phase-ai/src/policies/self_cost.rs
  • crates/phase-ai/src/policies/self_cost_value.rs
  • crates/phase-ai/src/policies/x_cast_gate.rs
  • crates/phase-ai/src/search.rs
  • crates/phase-ai/src/session.rs
  • crates/phase-ai/tests/cedh_integration.rs
  • crates/server-core/src/filter.rs
💤 Files with no reviewable changes (1)
  • crates/phase-ai/src/policies/mulligan/keepables_by_land_count.rs
🚧 Files skipped from review as they are similar to previous changes (46)
  • crates/engine/src/game/mod.rs
  • crates/engine/src/game/static_abilities.rs
  • crates/phase-ai/src/lib.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/src/game/triggers_pr7_order_template_tests.rs
  • crates/engine/src/game/archenemy_tests.rs
  • crates/engine/tests/integration/prospective_fetchland_mana.rs
  • crates/server-core/src/filter.rs
  • crates/engine/src/game/effects/end_phase.rs
  • crates/engine/src/game/engine_keyword_action_stack_tests.rs
  • crates/engine/src/game/cipher.rs
  • crates/engine/src/types/identifiers.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/cr733_resolved_trigger_collection.rs
  • crates/engine/src/game/effects/life.rs
  • crates/engine/tests/integration/cr733_resolved_modifier_install.rs
  • crates/engine/src/game/engine_priority.rs
  • crates/engine/src/game/engine_phase_trigger_regression_tests.rs
  • crates/engine-wasm/src/lib.rs
  • crates/phase-ai/src/session.rs
  • crates/engine/tests/integration/swarm_combat_witness.rs
  • crates/engine/src/game/elimination.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/phase-ai/src/policies/x_cast_gate.rs
  • crates/engine/src/game/engine_modes.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/tests/integration/pact_of_negation_upkeep_payment.rs
  • crates/engine/src/types/resolved_commands.rs
  • crates/engine/src/game/effects/draw.rs
  • crates/phase-ai/tests/cedh_integration.rs
  • crates/engine/src/ai_support/mod.rs
  • crates/phase-ai/src/planner/mod.rs
  • crates/engine/src/game/derived_views.rs
  • crates/engine/src/types/resolution.rs
  • crates/phase-ai/src/policies/effect_classify.rs
  • crates/phase-ai/src/policies/mulligan/card_floor.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/ai_support/evoke.rs
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/self_cost_value.rs
  • crates/engine/src/game/life_safety.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/ai_support/prospective_mana.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/stack.rs
  • crates/phase-ai/src/search.rs

Comment thread crates/engine/src/ai_support/swarm.rs Outdated
Comment thread crates/phase-ai/src/combat_ai.rs
Comment thread crates/phase-ai/src/policies/mulligan/mod.rs
Comment thread crates/phase-ai/src/policies/mulligan/mod.rs
Comment thread crates/phase-ai/src/policies/mulligan/mod.rs Outdated
Comment thread crates/phase-ai/src/policies/mulligan/mod.rs
Comment thread crates/phase-ai/src/policies/mulligan/mod.rs
Comment thread crates/phase-ai/src/policies/self_cost.rs Outdated
Comment thread crates/phase-ai/src/policies/self_cost.rs
@matthewevans
matthewevans force-pushed the ship/ai-reliability-fixes branch 5 times, most recently from b7f9c61 to ac98f31 Compare August 1, 2026 04:03

@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: 4

Caution

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

⚠️ Outside diff range comments (1)
crates/phase-ai/src/policies/self_cost.rs (1)

416-445: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A chain of drawbacks plus an unmodeled rider reports Trivial, bypassing the fail-open rule.

The Drawback arm does not set any_nontrivial. The !any_nontrivial early return at line 432 runs before the any_unmodeled check at line 438. So a chain of one Drawback plus one Unmodeled effect returns BenefitAppraisal::Trivial, which the caller reads as "no real payoff". The comment at lines 439-443 states the opposite intent: the unmodeled rider population carries both signs, so no directional conclusion is sound.

Order the unmodeled check ahead of the all-trivial return, or make the all-trivial return require that nothing was unmodeled.

🐛 Proposed fix
-    if !any_nontrivial {
+    if any_unmodeled {
+        // An unmodeled rider can be a benefit or a drawback, so neither the
+        // all-trivial conclusion nor the summed total is sound.
+        return BenefitAppraisal::Unpriced;
+    }
+    if !any_nontrivial {
         // All-trivial AND all-unmodeled chains land here — preserving the old
         // `benefit_is_trivial() == true` behaviour byte for byte (the catch-all
         // mapped to trivial), so the reject/marginal gate is not weakened.
         return BenefitAppraisal::Trivial;
     }
-    if any_unmodeled {
-        return BenefitAppraisal::Unpriced;
-    }
     BenefitAppraisal::Priced { value: total }

Note the deliberate behavior this changes: an all-unmodeled chain currently returns Trivial to preserve the pre-change gate. If that must be kept, gate the new early return on any_nontrivial || total != 0.0 instead, and record the reason 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/phase-ai/src/policies/self_cost.rs` around lines 416 - 445, Ensure
unmodeled effects cannot be classified as Trivial when combined with drawbacks:
update the appraisal flow around the any_nontrivial and any_unmodeled checks so
any_unmodeled is handled first, or require the all-trivial return to confirm no
unmodeled effects. Preserve the existing all-unmodeled compatibility behavior
only if required, while ensuring a drawback plus an unmodeled rider returns
BenefitAppraisal::Unpriced.
♻️ Duplicate comments (3)
crates/phase-ai/src/policies/mulligan/mod.rs (3)

196-199: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The _ => false fallback is still present.

GameAction is a known enum. Any cast or hand-activation variant that is not listed reads as "no opening action". The failure is one-directional: a missed variant makes is_certified_dead_landless() return true, the card floor abstains, and the AI mulligans a keepable hand. A new CastSpellAs* variant produces that regression with no compile error.

This was raised on an earlier commit and marked addressed, but the wildcard remains in the code under review.

As per coding guidelines: "Use exhaustive match expressions without wildcard fallbacks when matching known enums, allowing the compiler to detect missing variants."

🤖 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/phase-ai/src/policies/mulligan/mod.rs` around lines 196 - 199, Replace
the wildcard fallback in the GameAction match within
is_certified_dead_landless() with an exhaustive match over every GameAction
variant. Ensure all cast and hand-activation variants are handled consistently
so newly added variants cause a compile-time error until explicitly classified,
while preserving the existing behavior for listed variants.

Source: Coding guidelines


110-115: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

for_hand still derives the player from controller instead of owner.

hand is a non-battlefield zone. Objects there must be attributed by obj.owner. A hand object can carry a controller that differs from its owner, and the probe then clones a state whose active_player and priority_player are the wrong seat, so the forecast reports another player's opening window. The single find_map also binds an arbitrary object, so a mixed-ownership slice silently selects one seat.

This was raised on an earlier commit and marked addressed, but the code under review still reads controller. Please confirm which commit is current.

🐛 Proposed fix
-        let Some(player) = hand
-            .iter()
-            .find_map(|object_id| state.objects.get(object_id).map(|object| object.controller))
-        else {
-            return Self::default();
-        };
+        let mut owners = hand
+            .iter()
+            .filter_map(|object_id| state.objects.get(object_id).map(|object| object.owner));
+        let Some(player) = owners.next() else {
+            return Self::default();
+        };
+        // A mixed-ownership slice is not one player's opening hand.
+        if owners.any(|owner| owner != player) {
+            return Self::default();
+        }

As per path instructions: "Player-scoped queries on NON-battlefield zones (graveyard/library/hand/exile) must filter by obj.owner, not controller (CR 404.2)."

🤖 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/phase-ai/src/policies/mulligan/mod.rs` around lines 110 - 115, Update
for_hand’s player selection to use each hand object’s owner rather than
controller, and ensure the hand is attributed consistently instead of selecting
an arbitrary object from a mixed-ownership slice. Use the owner-derived player
when cloning the probe state so active_player and priority_player represent that
owner’s opening window.

Source: Path instructions


894-919: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Both negative assertions still lack a paired positive reach-guard.

Each assertion expects is_certified_dead_landless() == true. Both pass whenever the fixture object never reaches legal_actions_full at all, for example if activation_zone = Some(Zone::Hand) is not honored, if the pushed ability is not indexed, or if the Spell ability shape is rejected before targeting. The test then proves nothing about cost or target rejection.

Add a control per case that flips only the rejected condition:

  • Unaffordable activation: add enough mana for the {2} cost (or set AbilityCost::Tap) and assert the forecast is no longer certified dead.
  • Targetless spell: put a creature on the battlefield and assert the forecast is no longer certified dead.

This was raised on an earlier commit and marked addressed, but neither control is present in the code under review.

As per path instructions: "For every negative assertion (!detector(...), "not applied", "does not parse to X"), require a paired positive reach-guard proving the input actually reached the code under test".

🤖 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/phase-ai/src/policies/mulligan/mod.rs` around lines 894 - 919, The
negative cases in forecast_rejects_unaffordable_or_targetless_opening_actions
lack positive reach-guards. Add a paired control for each fixture: provide
enough mana or otherwise satisfy the activation cost and assert the forecast is
no longer certified dead, and add a battlefield creature for the targetless
spell and assert the forecast is no longer certified dead. Keep each control
limited to changing only the rejected cost or targeting condition.

Source: Path instructions

🧹 Nitpick comments (8)
crates/engine/tests/integration/draw_delivery_preview.rs (1)

329-333: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cross-check the empty-library exact zero against the live pipeline.

Every other Exact row in this file pairs the preview with completed_delivery on an independent runner. This row does not. The empty-library zero is then asserted only by the code under test, so a preview that wrongly reports zero on an empty library would still pass.

♻️ Proposed addition of the live sibling
     preview_is_read_only(
         &runner(0, Vec::new()),
         1,
         DrawDeliveryPreview::Exact { delivered: 0 },
     );
+    assert_eq!(completed_delivery(runner(0, Vec::new()), 1).0, 0);
     preview_is_read_only(

As per path instructions, a test must exercise the failure path the fix prevents and drive the engine through its production pipeline.

🤖 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/draw_delivery_preview.rs` around lines 329 -
333, Update the empty-library exact-zero case in preview_is_read_only to also
compare against completed_delivery using an independent runner, matching the
other Exact cases. Drive both the preview and live production pipeline so the
test fails if an empty library incorrectly reports zero delivery.

Source: Path instructions

crates/engine/src/game/effects/draw.rs (1)

64-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Correct the CR 121.2b citation. CR 121.2 covers sequential individual draws. CR 121.2b covers per-turn draw restrictions. CR 121.6b correctly describes completing a replacement before resuming the remaining draws.

🤖 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/draw.rs` around lines 64 - 80, Correct the
documentation comment above the draw-count preview function by removing the CR
121.2b citation from the description of multi-card sequential draws. Keep CR
121.2 for sequential individual draws and retain CR 121.6b for replacement
completion before resuming the sequence.

Source: Path instructions

crates/phase-ai/src/policies/self_cost.rs (1)

397-431: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Merge the identical NonTrivial and Drawback pricing bodies.

The two arms differ only in the any_nontrivial = true assignment. The effect_benefit_value call and its Some/None handling are duplicated verbatim across lines 403-415 and 417-428.

♻️ Proposed fix
         match triviality {
             EffectTriviality::Trivial => continue,
-            EffectTriviality::Unmodeled => any_unmodeled = true,
-            EffectTriviality::NonTrivial => {
-                any_nontrivial = true;
-                match effect_benefit_value(...) {
-                    Some(value) => total += value,
-                    None => return BenefitAppraisal::Unpriced,
-                }
-            }
-            EffectTriviality::Drawback => {
-                match effect_benefit_value(...) {
-                    Some(value) => total += value,
-                    None => return BenefitAppraisal::Unpriced,
-                }
-            }
+            EffectTriviality::Unmodeled => {
+                any_unmodeled = true;
+                continue;
+            }
+            EffectTriviality::NonTrivial | EffectTriviality::Drawback => {
+                any_nontrivial |= triviality == EffectTriviality::NonTrivial;
+                match effect_benefit_value(
+                    state,
+                    ai_player,
+                    source_id,
+                    effect,
+                    recipient,
+                    &mut pricing,
+                    penalties,
+                ) {
+                    Some(value) => total += value,
+                    None => return BenefitAppraisal::Unpriced,
+                }
+            }
         }
🤖 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/phase-ai/src/policies/self_cost.rs` around lines 397 - 431, Merge the
EffectTriviality::NonTrivial and EffectTriviality::Drawback arms in the
classified loop so they share one effect_benefit_value call and identical
Some/None handling. Preserve setting any_nontrivial only for NonTrivial before
executing the shared pricing logic.
crates/phase-ai/src/search.rs (4)

163-170: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the durable_pact_routes boolean with a typed mode enum.

The parameter controls five separate branches (retain_live_pact_route, draft, arm, filter, fallback filter). At the call sites it appears as a bare false (line 149) and true (line 160), which carries no meaning to a reader. A two-variant enum states the contract directly.

♻️ Proposed shape
/// Whether the caller owns an authoritative session that can retain an opaque
/// Pact receipt across turns.
enum PactRouteMode {
    /// Stateless entry point: certified Pact roots are excluded.
    Stateless,
    /// Caller-owned session: certified Pact roots are drafted and armed.
    Durable,
}

Then choose_action passes PactRouteMode::Stateless and choose_action_with_session passes PactRouteMode::Durable, and each branch reads matches!(mode, PactRouteMode::Durable).

As per coding guidelines: "Prefer typed enums and existing parameterized types such as ControllerRef, Comparator, and Option<T> over raw booleans or proliferating sibling enum variants".

🤖 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/phase-ai/src/search.rs` around lines 163 - 170, Replace the
durable_pact_routes boolean with a two-variant PactRouteMode enum in
choose_action_with_session_inner, defining Stateless and Durable semantics.
Update choose_action to pass PactRouteMode::Stateless and
choose_action_with_session to pass PactRouteMode::Durable, then make all five
route branches use matches!(mode, PactRouteMode::Durable) instead of boolean
checks.

Source: Coding guidelines


287-291: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse remove_certified_pact_roots for the stateless retain.

Line 290 duplicates the predicate that remove_certified_pact_roots (line 2463) already owns. Two copies can diverge when the certification rule changes.

♻️ Proposed fix
     } else {
-        scored.retain(|(action, _)| !is_certified_pact_root(state, ai_player, action));
+        remove_certified_pact_roots(state, ai_player, &mut scored);
     }
🤖 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/phase-ai/src/search.rs` around lines 287 - 291, Replace the inline
retain predicate in the stateless branch of the scored-action flow with the
existing remove_certified_pact_roots helper. Reuse that helper’s certification
logic while preserving the current durable_pact_routes branch and the resulting
filtering behavior.

367-370: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unreachable fetch-route guard.

This branch requires an empty hand, so hand_identity_bindings is empty and has_certified_fetch_then_cast_route always returns false. Remove the guard or document it as protection against a future change to the hand precondition.

🤖 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/phase-ai/src/search.rs` around lines 367 - 370, Remove the unreachable
has_certified_fetch_then_cast_route guard from the
large_board_main_phase_has_no_development_sources branch and return
GameAction::PassPriority directly when the condition holds.

4211-4214: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use one configured card-data path for both tests.

Neither hard-coded path is present or tracked in the repository. CardDatabase::from_export(...).expect(...) will panic when either test runs without an externally generated export. Use one shared resolver, such as the PHASE_CARDS_PATH convention, or add an explicit test-data generation step.

🤖 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/phase-ai/src/search.rs` around lines 4211 - 4214, Update the test
setup around CardDatabase::from_export to resolve card data through one shared
configured path instead of the hard-coded client/public/card-data.json location.
Reuse the existing PHASE_CARDS_PATH convention or shared resolver for both
tests, while preserving the expect-based failure behavior when the configured
export cannot be loaded.
crates/phase-ai/src/policies/mulligan/mod.rs (1)

125-167: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the single-element for loop.

for turn_number in [first_turn] iterates once. The loop form and the |= accumulation at line 151 are residue from the earlier two-window model, and the doc comment at line 96 now describes one window. A straight-line body states the single-probe contract directly and lets usable_nonland_mana_source be a plain assignment.

🤖 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/phase-ai/src/policies/mulligan/mod.rs` around lines 125 - 167, Replace
the single-element for loop around the opening probe with straight-line code
using first_turn directly. Update the usable_nonland_mana_source accumulation to
a direct assignment, while preserving the existing action scanning and single
precombat-main probe behavior.
🤖 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/phase-ai/src/policies/self_cost.rs`:
- Around line 471-486: Update the chained AI-only draw handling in the
surrounding pricing state and Effect::Draw branch to track whether an AI draw
has been previewed independently of pricing.ai_draws_so_far. Set that marker
whenever preview_draw_delivery returns Exact, including delivered == 0, and use
it to return None for subsequent chained draws while preserving ai_draws_so_far
for delivered-card valuation.
- Around line 462-470: Correct the replacement-effect citations in the comments
at crates/phase-ai/src/policies/self_cost.rs lines 462-470 and
crates/phase-ai/src/search.rs lines 2260-2264: use CR 615.1 for prevention
effects and reserve CR 614.11a for replacement effects within a sequence of
draws. Keep all other cited rules unchanged.

In `@crates/phase-ai/src/search.rs`:
- Around line 3780-3788: Update select_safe_action_from_scores to remove Pact
payment casts from scored before calling softmax_select_pairs, so selection only
considers legal candidates and returns None only when all candidates are
filtered out. Preserve the existing state-aware filtering via
is_pact_payment_cast, and add a mixed-candidate test alongside
stateless_pact_apis_never_return_a_certified_pact to verify the non-Pact action
remains selectable.
- Around line 220-229: Move the prospective_fetch_follow_up insertion in the
action_for handling block so it occurs only after exact_contract_action(action)
confirms the action is accepted. Preserve the existing behavior of returning the
contracted action, and avoid storing prompt.follow_up() when the contract check
rejects it.

---

Outside diff comments:
In `@crates/phase-ai/src/policies/self_cost.rs`:
- Around line 416-445: Ensure unmodeled effects cannot be classified as Trivial
when combined with drawbacks: update the appraisal flow around the
any_nontrivial and any_unmodeled checks so any_unmodeled is handled first, or
require the all-trivial return to confirm no unmodeled effects. Preserve the
existing all-unmodeled compatibility behavior only if required, while ensuring a
drawback plus an unmodeled rider returns BenefitAppraisal::Unpriced.

---

Duplicate comments:
In `@crates/phase-ai/src/policies/mulligan/mod.rs`:
- Around line 196-199: Replace the wildcard fallback in the GameAction match
within is_certified_dead_landless() with an exhaustive match over every
GameAction variant. Ensure all cast and hand-activation variants are handled
consistently so newly added variants cause a compile-time error until explicitly
classified, while preserving the existing behavior for listed variants.
- Around line 110-115: Update for_hand’s player selection to use each hand
object’s owner rather than controller, and ensure the hand is attributed
consistently instead of selecting an arbitrary object from a mixed-ownership
slice. Use the owner-derived player when cloning the probe state so
active_player and priority_player represent that owner’s opening window.
- Around line 894-919: The negative cases in
forecast_rejects_unaffordable_or_targetless_opening_actions lack positive
reach-guards. Add a paired control for each fixture: provide enough mana or
otherwise satisfy the activation cost and assert the forecast is no longer
certified dead, and add a battlefield creature for the targetless spell and
assert the forecast is no longer certified dead. Keep each control limited to
changing only the rejected cost or targeting condition.

---

Nitpick comments:
In `@crates/engine/src/game/effects/draw.rs`:
- Around line 64-80: Correct the documentation comment above the draw-count
preview function by removing the CR 121.2b citation from the description of
multi-card sequential draws. Keep CR 121.2 for sequential individual draws and
retain CR 121.6b for replacement completion before resuming the sequence.

In `@crates/engine/tests/integration/draw_delivery_preview.rs`:
- Around line 329-333: Update the empty-library exact-zero case in
preview_is_read_only to also compare against completed_delivery using an
independent runner, matching the other Exact cases. Drive both the preview and
live production pipeline so the test fails if an empty library incorrectly
reports zero delivery.

In `@crates/phase-ai/src/policies/mulligan/mod.rs`:
- Around line 125-167: Replace the single-element for loop around the opening
probe with straight-line code using first_turn directly. Update the
usable_nonland_mana_source accumulation to a direct assignment, while preserving
the existing action scanning and single precombat-main probe behavior.

In `@crates/phase-ai/src/policies/self_cost.rs`:
- Around line 397-431: Merge the EffectTriviality::NonTrivial and
EffectTriviality::Drawback arms in the classified loop so they share one
effect_benefit_value call and identical Some/None handling. Preserve setting
any_nontrivial only for NonTrivial before executing the shared pricing logic.

In `@crates/phase-ai/src/search.rs`:
- Around line 163-170: Replace the durable_pact_routes boolean with a
two-variant PactRouteMode enum in choose_action_with_session_inner, defining
Stateless and Durable semantics. Update choose_action to pass
PactRouteMode::Stateless and choose_action_with_session to pass
PactRouteMode::Durable, then make all five route branches use matches!(mode,
PactRouteMode::Durable) instead of boolean checks.
- Around line 287-291: Replace the inline retain predicate in the stateless
branch of the scored-action flow with the existing remove_certified_pact_roots
helper. Reuse that helper’s certification logic while preserving the current
durable_pact_routes branch and the resulting filtering behavior.
- Around line 367-370: Remove the unreachable
has_certified_fetch_then_cast_route guard from the
large_board_main_phase_has_no_development_sources branch and return
GameAction::PassPriority directly when the condition holds.
- Around line 4211-4214: Update the test setup around CardDatabase::from_export
to resolve card data through one shared configured path instead of the
hard-coded client/public/card-data.json location. Reuse the existing
PHASE_CARDS_PATH convention or shared resolver for both tests, while preserving
the expect-based failure behavior when the configured export cannot be loaded.
🪄 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: d7c8ca9c-38f9-4f51-80b5-66d3f33813c2

📥 Commits

Reviewing files that changed from the base of the PR and between 74a6f4c and ac98f31.

📒 Files selected for processing (73)
  • crates/engine-wasm/src/lib.rs
  • crates/engine/src/ai_support/evoke.rs
  • crates/engine/src/ai_support/mod.rs
  • crates/engine/src/ai_support/prospective_mana.rs
  • crates/engine/src/ai_support/swarm.rs
  • crates/engine/src/game/archenemy_tests.rs
  • crates/engine/src/game/blitz.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/game/cipher.rs
  • crates/engine/src/game/dash.rs
  • crates/engine/src/game/derived_views.rs
  • crates/engine/src/game/effects/cast_copy_of_card.rs
  • crates/engine/src/game/effects/copy_spell.rs
  • crates/engine/src/game/effects/counters.rs
  • crates/engine/src/game/effects/delayed_trigger.rs
  • crates/engine/src/game/effects/draw.rs
  • crates/engine/src/game/effects/encore.rs
  • crates/engine/src/game/effects/end_phase.rs
  • crates/engine/src/game/effects/epic.rs
  • crates/engine/src/game/effects/exile_resolving_spell.rs
  • crates/engine/src/game/effects/life.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/myriad.rs
  • crates/engine/src/game/effects/paradigm.rs
  • crates/engine/src/game/effects/phase_out.rs
  • crates/engine/src/game/effects/rebound.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/elimination.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_exile_return_tests.rs
  • crates/engine/src/game/engine_keyword_action_stack_tests.rs
  • crates/engine/src/game/engine_modes.rs
  • crates/engine/src/game/engine_phase_trigger_regression_tests.rs
  • crates/engine/src/game/engine_priority.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/src/game/life_safety.rs
  • crates/engine/src/game/mod.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/static_abilities.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/triggers_pr7_order_template_tests.rs
  • crates/engine/src/game/visibility.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/identifiers.rs
  • crates/engine/src/types/resolution.rs
  • crates/engine/src/types/resolved_commands.rs
  • crates/engine/tests/fixtures/integration_cards.json
  • crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs
  • crates/engine/tests/integration/cr733_resolved_modifier_install.rs
  • crates/engine/tests/integration/cr733_resolved_trigger_collection.rs
  • crates/engine/tests/integration/draw_delivery_preview.rs
  • crates/engine/tests/integration/lose_control_this_turn_delayed_trigger.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/mechtitan_core_return_exiled.rs
  • crates/engine/tests/integration/pact_of_negation_upkeep_payment.rs
  • crates/engine/tests/integration/prospective_fetchland_mana.rs
  • crates/engine/tests/integration/swarm_combat_witness.rs
  • crates/phase-ai/src/combat_ai.rs
  • crates/phase-ai/src/lib.rs
  • crates/phase-ai/src/planner/mod.rs
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/effect_classify.rs
  • crates/phase-ai/src/policies/mulligan/card_floor.rs
  • crates/phase-ai/src/policies/mulligan/keepables_by_land_count.rs
  • crates/phase-ai/src/policies/mulligan/mod.rs
  • crates/phase-ai/src/policies/self_cost.rs
  • crates/phase-ai/src/policies/self_cost_value.rs
  • crates/phase-ai/src/policies/x_cast_gate.rs
  • crates/phase-ai/src/search.rs
  • crates/phase-ai/src/session.rs
  • crates/phase-ai/tests/cedh_integration.rs
  • crates/server-core/src/filter.rs
💤 Files with no reviewable changes (1)
  • crates/phase-ai/src/policies/mulligan/keepables_by_land_count.rs
🚧 Files skipped from review as they are similar to previous changes (63)
  • crates/engine/src/game/effects/delayed_trigger.rs
  • crates/engine/src/game/effects/counters.rs
  • crates/engine/src/game/effects/cast_copy_of_card.rs
  • crates/engine/src/game/dash.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/game/blitz.rs
  • crates/phase-ai/tests/cedh_integration.rs
  • crates/engine/tests/integration/mechtitan_core_return_exiled.rs
  • crates/engine/src/game/archenemy_tests.rs
  • crates/engine/src/game/cipher.rs
  • crates/engine/tests/integration/cr733_resolved_trigger_collection.rs
  • crates/engine/src/game/effects/copy_spell.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/effects/end_phase.rs
  • crates/engine/src/game/effects/epic.rs
  • crates/engine/src/game/derived_views.rs
  • crates/engine/src/game/effects/myriad.rs
  • crates/engine/src/ai_support/mod.rs
  • crates/engine/tests/integration/cr733_resolved_modifier_install.rs
  • crates/engine/src/game/static_abilities.rs
  • crates/engine/src/game/effects/phase_out.rs
  • crates/phase-ai/src/session.rs
  • crates/engine/src/game/triggers_pr7_order_template_tests.rs
  • crates/engine/src/game/engine_modes.rs
  • crates/engine-wasm/src/lib.rs
  • crates/engine/src/game/effects/encore.rs
  • crates/engine/src/game/engine_priority.rs
  • crates/engine/src/game/mod.rs
  • crates/engine/src/game/engine_exile_return_tests.rs
  • crates/engine/src/game/effects/rebound.rs
  • crates/engine/src/game/engine_keyword_action_stack_tests.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs
  • crates/engine/src/game/effects/exile_resolving_spell.rs
  • crates/engine/src/game/effects/paradigm.rs
  • crates/engine/src/game/engine_phase_trigger_regression_tests.rs
  • crates/engine/src/types/identifiers.rs
  • crates/engine/tests/integration/swarm_combat_witness.rs
  • crates/engine/src/game/effects/life.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/engine/src/game/visibility.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/types/resolution.rs
  • crates/engine/src/ai_support/swarm.rs
  • crates/phase-ai/src/lib.rs
  • crates/engine/src/game/elimination.rs
  • crates/engine/src/game/life_safety.rs
  • crates/engine/tests/integration/lose_control_this_turn_delayed_trigger.rs
  • crates/engine/src/types/resolved_commands.rs
  • crates/phase-ai/src/planner/mod.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/triggers.rs
  • crates/server-core/src/filter.rs
  • crates/engine/src/ai_support/prospective_mana.rs
  • crates/phase-ai/src/policies/x_cast_gate.rs
  • crates/engine/src/ai_support/evoke.rs
  • crates/phase-ai/src/policies/effect_classify.rs
  • crates/phase-ai/src/policies/self_cost_value.rs
  • crates/phase-ai/src/policies/mulligan/card_floor.rs

Comment on lines +462 to +470
// CR 121.1 + CR 121.2: one delivered card is one registry score unit,
// but a multi-card instruction may deliver only a partial count. The
// engine-owned preview runs that exact cloned instruction rather than
// duplicating draw or replacement logic here.
//
// CR 614.6 + CR 614.11: a draw the live pipeline REMOVES — mandatorily
// prevented, substituted away by a non-Draw chain (Notion Thief,
// Hullbreacher, Chains of Mephistopheles), or count-modified to zero —
// "never happens"; a `CantDraw` static, an exhausted `PerTurnDrawLimit`,
// or an empty library (CR 704.5b, where a mere zero is if anything an
// UNDER-penalty) likewise delivers no card. Such a draw buys exactly
// nothing, so it is priced 0.0 — priced, NOT `None`: `None` is
// `BenefitAppraisal::Unpriced`, which stands the whole comparison down
// and ALLOWS the activation, the opposite of what the engine has just
// certified. The zero is not a heuristic: `can_draw_at_least_one`
// delegates every leg to the authority the live pipeline itself uses
// (`proposed_draw_survives_replacement`), and an OPTIONAL replacement is
// never assumed to apply, so a deliverable draw is never under-priced.
//
// Deliberately binary, not count-aware. `can_draw_at_least_one` asks
// only whether ONE card arrives, so two legs still price the full
// nominal count of a draw the engine will only partially deliver:
// 1. Draw-limit headroom shorter than the count: with a
// `PerTurnDrawLimit` headroom of 1 a `draw(3)` still prices 3.0
// (CR 121.2 performs it as three individual draws, only one of
// which is permitted).
// 2. LIBRARY shorter than the count — strictly the worse leg. The
// preflight probes with count 1, so a 1-card library against a
// `draw(3)` reports deliverable and prices 3.0; one card arrives
// and the two failed draw attempts LOSE the game at the next SBA
// check (CR 704.5b). `select_cards_to_draw` is explicit that a
// partial draw (`count > library.len()`) yields only what is
// available, so this is a certified loss the net comparison cannot
// see — not merely an over-price.
// Both legs are PRE-EXISTING (this arm priced 3.0 before the
// deliverability gate existed) and are DISCLOSED, not fixed, here.
// Over-pricing is the conservative direction for this module's charter
// — it can let a marginal crack through, never forbid a paying one —
// but note that over-pricing a benefit is under-vetoing, so neither leg
// should be read as harmless. Pricing them correctly needs a new `pub`
// engine authority ("how many of N will actually arrive"), which is
// tracked as its own unit rather than built for one consumer here.
Effect::Draw {
// CR 614.6 + CR 614.11a: prevention and fully settled non-draw
// substitutions are exact zeroes. A choice required to order or accept
// a replacement—or raised by its continuation—is not a zero; the policy
// returns `None` so `BenefitAppraisal::Unpriced` fails open.

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
set -euo pipefail
RULES=$(fd -t f 'MagicCompRules.txt' | head -1)
echo "pinned rules file: $RULES"
for rule in '121\.1' '121\.2' '614\.6' '614\.11a' '701\.9' '609\.3' '702\.74'; do
  printf '\n===== CR %s =====\n' "$rule"
  rg -n -A4 "^${rule}[.a-z]" "$RULES" || echo "NOT FOUND"
done
printf '\n===== Evoke entry =====\n'
rg -n -B2 -A6 '^702\.[0-9]+\. Evoke' "$RULES" || echo "Evoke heading NOT FOUND"

Repository: phase-rs/phase

Length of output: 1039


🏁 Script executed:

set -euo pipefail
printf '%s\n' '== candidate rules files =='
git ls-files | rg -i '(^|/)(MagicCompRules|.*Comprehensive.*Rules|.*rules.*txt)(\.[^/]*)?$' || true
printf '%s\n' '== referenced source files =='
git ls-files crates/phase-ai/src/policies/self_cost.rs crates/phase-ai/src/search.rs
printf '%s\n' '== citation context =='
rg -n -A12 -B4 'CR 121\.1|CR 614\.6|CR 609\.3|CR 702\.74' crates/phase-ai/src/policies/self_cost.rs crates/phase-ai/src/search.rs || true

Repository: phase-rs/phase

Length of output: 5724


🌐 Web query:

Magic Comprehensive Rules 121.1 121.2 614.6 614.11a 609.3 701.9a 702.74a official

💡 Result:

The following Magic: The Gathering Comprehensive Rules sections are defined as follows: 121.1: A player draws a card by putting the top card of their library into their hand [1]. This action occurs as a turn-based action during the draw step, or as part of a spell or ability's cost or effect [1]. 121.2: Cards must be drawn one at a time [1]. If a player is instructed to draw multiple cards, they perform that many individual draws [1]. 609.3: If an effect attempts to do something impossible, it does as much as possible [2]. For example, if an effect instructs a player to discard two cards but they only hold one, they discard only that one [2]. 614.6: If an event is replaced, the original event never occurs [3]. Instead, a modified event takes place, which may trigger abilities [3]. Any instructions in the modified event that cannot be carried out are simply ignored [3]. 614.11a: If an effect replaces a draw within a sequence of card draws, all actions required by the replacement must be completed, if possible, before the sequence of draws resumes [3]. 701.9a: This rule defines the keyword action "Exchange." It states that when a player exchanges two objects, each object currently under the control of a player is moved to the other player's control [4]. (Note: While 701.9a is the standard index for "Exchange," exact text may vary slightly by specific version updates). 702.74a: This rule defines "Evoke" [5][6]. Evoke represents two abilities: a static ability that functions in any zone from which the card can be cast, and a triggered ability that functions on the battlefield [5][6]. "Evoke [cost]" means "You may cast this card by paying [cost] rather than paying its mana cost" and "When this permanent enters, if its evoke cost was paid, its controller sacrifices it" [5][6]. (Note: In some versions of the Comprehensive Rules, 702.74 also refers to "Hideaway," which represents a static ability and a triggered ability [7][8]. Always verify the specific rules version for the intended card mechanic.)

Citations:


🌐 Web query:

official Magic Comprehensive Rules CR 614.11a replacement effect sequence of draws CR 614.6 exact text

💡 Result:

According to the official Magic: The Gathering Comprehensive Rules (as of April 17, 2026), rule 614.11a describes the sequence of events when a card draw is replaced. The exact text of rule 614.11a is: "If an effect replaces a draw within a sequence of card draws, all actions required by the replacement are completed, if possible, before resuming the sequence." [1][2][3] Rule 614.6 defines when replacement effects occur: "A replacement effect doesn't invoke itself repeatedly; it gets only one opportunity to affect an event or any modified events that may replace it." [Note: This is the standard definition of CR 614.6 across recent versions.] These rules are part of the broader section 614, which governs Replacement Effects. [4][5]

Citations:


🌐 Web query:

official Magic Comprehensive Rules CR 701.9a discard exchange exact text CR 609.3

💡 Result:

The official Magic: The Gathering Comprehensive Rules define the requested sections as follows: 701.9a: To discard a card, move it from its owner’s hand to that player’s graveyard [1]. 609.3: If an effect attempts to do something impossible, it does only as much as possible [2]. These rules are standard in the Comprehensive Rules documentation [3][4]. While rule numbering for specific keyword actions such as "Discard" may occasionally shift in different versions of the rules, the quoted text above represents the authoritative definition for these game concepts [1][5][2].

Citations:


🏁 Script executed:

set -euo pipefail
url='https://media.wizards.com/2026/downloads/MagicCompRules%2020260417.txt'
tmp=$(mktemp)
curl -fsSL "$url" -o "$tmp"
printf '%s\n' "retrieved: $url"
for rule in '609\.3' '614\.6' '614\.11a' '701\.9a' '702\.74a'; do
  printf '\n===== CR %s =====\n' "$rule"
  rg -n -A5 "^${rule}[[:space:]]" "$tmp" || echo "NOT FOUND"
done
rm -f "$tmp"

Repository: phase-rs/phase

Length of output: 3515


🏁 Script executed:

set -euo pipefail
url='https://media.wizards.com/2026/downloads/MagicCompRules%2020260417.txt'
tmp=$(mktemp)
curl -fsSL "$url" -o "$tmp"
for section in 609 614; do
  printf '\n===== section %s headings =====\n' "$section"
  rg -n "^${section}\." "$tmp" | head -40
done
printf '\n===== nearby 609/614 text =====\n'
awk 'BEGIN{p=0} /^609\./{p=1} p{print NR ":" $0} /^610\./{exit}' "$tmp"
awk 'BEGIN{p=0} /^614\./{p=1} p{print NR ":" $0} /^615\./{exit}' "$tmp"
rm -f "$tmp"

Repository: phase-rs/phase

Length of output: 14946


🏁 Script executed:

set -euo pipefail
printf '%s\n' '===== self_cost.rs 450-525 ====='
sed -n '450,525p' crates/phase-ai/src/policies/self_cost.rs
printf '%s\n' '===== self_cost.rs replacement-related symbols ====='
rg -n -C3 'preview_draw_delivery|replacement|prevention|ai_draws_so_far|BenefitAppraisal' crates/phase-ai/src/policies/self_cost.rs

Repository: phase-rs/phase

Length of output: 10988


Correct the replacement-effect citations. CR 614.11a applies only to replacement effects within a sequence of draws. Use CR 615.1 for prevention effects. The other cited rules are applicable.

📍 Affects 2 files
  • crates/phase-ai/src/policies/self_cost.rs#L462-L470 (this comment)
  • crates/phase-ai/src/search.rs#L2260-L2264
🤖 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/phase-ai/src/policies/self_cost.rs` around lines 462 - 470, Correct
the replacement-effect citations in the comments at
crates/phase-ai/src/policies/self_cost.rs lines 462-470 and
crates/phase-ai/src/search.rs lines 2260-2264: use CR 615.1 for prevention
effects and reserve CR 614.11a for replacement effects within a sequence of
draws. Keep all other cited rules unchanged.

Sources: Path instructions, Learnings

Comment thread crates/phase-ai/src/policies/self_cost.rs
Comment on lines +220 to +229
if let Ok(mut pending) = session.prospective_fetch_prompt.write() {
if let Some(prompt) = pending.remove(&ai_player) {
if let Some(action) = prompt.action_for(state, ai_player) {
if let Ok(mut follow_ups) = session.prospective_fetch_follow_up.write() {
follow_ups.insert(ai_player, prompt.follow_up());
}
return exact_contract_action(action);
}
}
}

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Store the fetch follow-up only after the contract check passes.

The code inserts prompt.follow_up() into the session, then returns exact_contract_action(action). If the contract rejects action, the function returns None while the follow-up stays armed for the next decision. Move the insert after the contract check so the follow-up is armed only when the fetch selection is actually returned.

🐛 Proposed fix
             if let Some(prompt) = pending.remove(&ai_player) {
                 if let Some(action) = prompt.action_for(state, ai_player) {
-                    if let Ok(mut follow_ups) = session.prospective_fetch_follow_up.write() {
-                        follow_ups.insert(ai_player, prompt.follow_up());
+                    let Some(action) = exact_contract_action(action) else {
+                        return None;
+                    };
+                    if let Ok(mut follow_ups) = session.prospective_fetch_follow_up.write() {
+                        follow_ups.insert(ai_player, prompt.follow_up());
                     }
-                    return exact_contract_action(action);
+                    return Some(action);
                 }
             }
📝 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
if let Ok(mut pending) = session.prospective_fetch_prompt.write() {
if let Some(prompt) = pending.remove(&ai_player) {
if let Some(action) = prompt.action_for(state, ai_player) {
if let Ok(mut follow_ups) = session.prospective_fetch_follow_up.write() {
follow_ups.insert(ai_player, prompt.follow_up());
}
return exact_contract_action(action);
}
}
}
if let Ok(mut pending) = session.prospective_fetch_prompt.write() {
if let Some(prompt) = pending.remove(&ai_player) {
if let Some(action) = prompt.action_for(state, ai_player) {
let Some(action) = exact_contract_action(action) else {
return None;
};
if let Ok(mut follow_ups) = session.prospective_fetch_follow_up.write() {
follow_ups.insert(ai_player, prompt.follow_up());
}
return Some(action);
}
}
}
🤖 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/phase-ai/src/search.rs` around lines 220 - 229, Move the
prospective_fetch_follow_up insertion in the action_for handling block so it
occurs only after exact_contract_action(action) confirms the action is accepted.
Preserve the existing behavior of returning the contracted action, and avoid
storing prompt.follow_up() when the contract check rejects it.

Comment on lines +3780 to +3788
pub fn select_safe_action_from_scores(
state: &GameState,
scored: &[(GameAction, f64)],
temperature: f64,
rng: &mut impl Rng,
) -> Option<GameAction> {
if scored.is_empty() {
return None;
}
softmax_select_pairs(scored, temperature, rng)
.filter(|action| !is_pact_payment_cast(state, action))
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Filter Pact casts before the softmax, not after.

The current order runs softmax_select_pairs over the full vector and discards the result when it is a Pact payment cast. Two consequences:

  1. The function returns None while non-Pact candidates remain in scored, so a caller that treats None as "no action available" loses a legal play.
  2. The outcome depends on the RNG draw. The same scored slice returns an action on one call and None on another, which makes the caller contract untestable.

Filter first. Then None means "every candidate needs a durable Pact route", which is the signal the doc comment describes.

🐛 Proposed fix
 pub fn select_safe_action_from_scores(
     state: &GameState,
     scored: &[(GameAction, f64)],
     temperature: f64,
     rng: &mut impl Rng,
 ) -> Option<GameAction> {
-    softmax_select_pairs(scored, temperature, rng)
-        .filter(|action| !is_pact_payment_cast(state, action))
+    let safe: Vec<(GameAction, f64)> = scored
+        .iter()
+        .filter(|(action, _)| !is_pact_payment_cast(state, action))
+        .cloned()
+        .collect();
+    softmax_select_pairs(&safe, temperature, rng)
 }

The existing test stateless_pact_apis_never_return_a_certified_pact_root still passes, because its single candidate is a Pact cast. Add a mixed-vector case so the "keeps the non-Pact candidate" behavior is pinned.

📝 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
pub fn select_safe_action_from_scores(
state: &GameState,
scored: &[(GameAction, f64)],
temperature: f64,
rng: &mut impl Rng,
) -> Option<GameAction> {
if scored.is_empty() {
return None;
}
softmax_select_pairs(scored, temperature, rng)
.filter(|action| !is_pact_payment_cast(state, action))
}
pub fn select_safe_action_from_scores(
state: &GameState,
scored: &[(GameAction, f64)],
temperature: f64,
rng: &mut impl Rng,
) -> Option<GameAction> {
let safe: Vec<(GameAction, f64)> = scored
.iter()
.filter(|(action, _)| !is_pact_payment_cast(state, action))
.cloned()
.collect();
softmax_select_pairs(&safe, temperature, rng)
}
🤖 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/phase-ai/src/search.rs` around lines 3780 - 3788, Update
select_safe_action_from_scores to remove Pact payment casts from scored before
calling softmax_select_pairs, so selection only considers legal candidates and
returns None only when all candidates are filtered out. Preserve the existing
state-aware filtering via is_pact_payment_cast, and add a mixed-candidate test
alongside stateless_pact_apis_never_return_a_certified_pact to verify the
non-Pact action remains selectable.

@matthewevans
matthewevans force-pushed the ship/ai-reliability-fixes branch from 8e8b172 to e4dec96 Compare August 1, 2026 15:16

@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.

🧹 Nitpick comments (1)
scripts/gen-test-fixture.py (1)

53-56: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider auto-discovering Phase AI fixture consumers instead of a hand-maintained list.

FIXTURE_CONSUMER_TEST_FILES is a manually maintained list with one entry today. src_fixture_files() already auto-discovers engine-crate consumers by scanning for the shared_card_db marker string. Apply the same pattern to Phase AI files (for example, scan crates/phase-ai for files that reference integration_cards.json) so a future test that loads the fixture directly is captured automatically instead of requiring a manual addition to this list.

♻️ Proposed refactor sketch
-FIXTURE_CONSUMER_TEST_FILES = [REPO_ROOT / "crates/phase-ai/src/search.rs"]
+PHASE_AI_DIR = REPO_ROOT / "crates/phase-ai"
+
+
+def fixture_consumer_test_files() -> list[Path]:
+    """Phase AI files that load the engine fixture directly."""
+    files = []
+    for rs in PHASE_AI_DIR.rglob("*.rs"):
+        text = rs.read_text(encoding="utf-8", errors="ignore")
+        if "integration_cards.json" in text:
+            files.append(rs)
+    return files

Also applies to: 82-86

🤖 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 `@scripts/gen-test-fixture.py` around lines 53 - 56, Replace the manually
maintained FIXTURE_CONSUMER_TEST_FILES list with automatic discovery in
src_fixture_files, scanning crates/phase-ai for files referencing
integration_cards.json and including matching paths alongside the existing
engine-crate shared_card_db consumers. Preserve the current fixture path and
deduplication behavior.
🤖 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.

Nitpick comments:
In `@scripts/gen-test-fixture.py`:
- Around line 53-56: Replace the manually maintained FIXTURE_CONSUMER_TEST_FILES
list with automatic discovery in src_fixture_files, scanning crates/phase-ai for
files referencing integration_cards.json and including matching paths alongside
the existing engine-crate shared_card_db consumers. Preserve the current fixture
path and deduplication behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 369b2d21-73c1-4a34-8f66-72c6eaf02af6

📥 Commits

Reviewing files that changed from the base of the PR and between ac98f31 and 8e8b172.

⛔ Files ignored due to path filters (1)
  • crates/engine/tests/fixtures/cr733/authority_matrix.json.gz is excluded by !**/*.gz
📒 Files selected for processing (4)
  • crates/engine/src/analysis/resource.rs
  • crates/engine/tests/fixtures/integration_cards.json
  • crates/phase-ai/src/search.rs
  • scripts/gen-test-fixture.py

@matthewevans
matthewevans force-pushed the ship/ai-reliability-fixes branch from e4dec96 to f3511ef Compare August 1, 2026 15:50
@matthewevans
matthewevans added this pull request to the merge queue Aug 1, 2026
@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request Aug 1, 2026
@matthewevans
matthewevans added this pull request to the merge queue Aug 1, 2026
Merged via the queue into main with commit 8121fd1 Aug 1, 2026
4 checks passed
@matthewevans
matthewevans deleted the ship/ai-reliability-fixes branch August 1, 2026 16:45
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
Upstream phase-rs#6842 (8121fd1) made a `TriggerFiring` carrier MANDATORY on every
persisted triggered record and fails CLOSED without one. The six 4p dump
fixtures this branch drives were captured before that commit, so on the rebased
base they no longer decode at all: 41 of the 44 red rows were production-decoder
rejections naming the three carriers (`active legacy pending trigger has no
firing discriminator`, `triggered stack entry has no firing carrier`,
`resolving triggered entry has no firing carrier`).

The carrier cannot be RECOVERED, only DERIVED. The read-only pristine root
(`combofb-dumps-pristine/`, captured 2026-07-22/25) predates phase-rs#6842 too and
records zero firing carriers, so re-deriving from pristine cannot mint a value
that was never captured.

`TriggerFiring::UnknownLegacy` is not an escape hatch, and this was measured
rather than assumed: `validate_firing` (7 call sites, types/game_state.rs
:7688/:7691/:7700/:7721/:7732/:7749/:7779) returns
`Err("... has no canonical trigger firing discriminator")` for it. It is the
field-absent marker (`skip_serializing_if`) and the redaction default, never a
legal persisted value.

THE DISCRIMINANT (CR 603.1 ordinary vs CR 603.7a delayed), applied per record:
  Ordinary <= the fired trigger's definition is present on its SOURCE OBJECT's
              own `trigger_definitions`/`base_trigger_definitions`, matched by
              exact `description`. A printed or granted triggered ability of a
              permanent is an ordinary triggered ability.
  Delayed  <= the trigger has an install receipt in `delayed_triggers`.
Anything else ABORTS by name. There is deliberately no fallback stamp: a wrong
carrier silently re-classifies a CR 603.7 firing identity, which is precisely
the inference upstream refuses to make.

Measured, per fixture — carriers stamped, and `delayed_triggers` length:

| fixture | carriers | delayed_triggers | sha256 (stamped) |
|---|---|---|---|
| dellian_emblem_conqueror_4p | 154 | 0 | 3f8b06eb7986de10 |
| dina_conqueror_4p | 7 | 0 | 773e902daccf7199 |
| fantastic_four_bounded_loop_4p | 3 | 0 | 89ff377b53b7bd55 |
| tenacity_exquisite_blood_4p | 6 | 0 | a296ab6e37129ca3 |
| witherbloom_sprout_lumaret_4p | 1 | 0 | 71efd26867e9fc57 |
| witherbloom_sprout_lumaret_simple_4p | 1 | 0 | 607fbe632e759692 |

172 carriers total, ALL `Ordinary`, ZERO undetermined. Every fixture records
`delayed_triggers: []` and no install journal, so `Delayed(Some(prov))` could
not have validated regardless — `validate_firing` demands a registered install
root. Every one of the 172 was classified from its own record's source object,
never defaulted; the per-record witness table (carrier, source id, card name) is
in the PR body.

Five resolving entries elsewhere in the corpus are NOT migration targets and are
deliberately left unstamped: `combo_infinite_pile_4p_untapped_precast`,
`kilo_freed_relic_pentad_4p`, `sprout_witherbloom_realistic_lands_4p`,
`basalt_power_artifact_infinite_colorless`, `combo_infinite_pile_4p_offer`.
Each `kind.type` is `Spell` or `ActivatedAbility`, not `TriggeredAbility`, and
the validator's `(Some(_), None) => {}` arm accepts a non-triggered resolving
entry with no carrier. An earlier classifier of mine flagged these as
UNDETERMINED; reading each entry's `kind.type` showed the flag was wrong.

SECOND FIELD CLASS — the CR 603.7 delayed-trigger ALLOCATORS. Restoring the
dumps exposed a further phase-rs#6842 change that the decode failures had been masking:
`next_delayed_trigger_token` carries `#[serde(default)]`, so a pre-phase-rs#6842 dump
that omits it restores as 0 through a bare `GameState` decode, while the
production `PersistedGameState` path runs a load-time repair
(`next = max(existing // 1, max_used_token + 1)`) and restores 1. The two
decoders therefore disagree on a legacy dump, and 0 is invalid on its face:
`validate_trigger_firing_coherence` rejects
`next_delayed_trigger_token <= max_token`, which is 0 when there are no install
roots.

This surfaced as the R8 state-neutrality arm of
`migrated_dump_decodes_through_both_decoders_and_unmigrated_through_neither`
reporting `differing paths: ["state.next_delayed_trigger_token"]`. That arm is
NOT relaxed. The fixtures are stamped with the repaired value instead, which
makes them look like a modern capture, keeps both decoders in agreement, and
survives the eventual deletion of the load-time shim.

Measured, all six fixtures: field absent, `delayed_triggers: 0`, and ZERO
`DelayedTriggerInstall` commands across 158/30/2/516/50/5094 journal entries. So
both used-token sets are empty and the formula collapses to `max(1, 1) = 1`. The
scan is not vacuous: injecting one synthetic install command into a fixture makes
the same selector report 1 instead of 0.

The GENERAL derivation of the used-token set is deliberately NOT reimplemented
in jq — it walks `resolved_rules_journal` install commands and `delayed_triggers`
provenance with reuse and nonzero checks, and re-deriving it here would repeat
exactly the mistake this script's sibling refuses to make for `EffectKind`. Only
the collapsed no-install-roots case is stamped; anything else ABORTS BY NAME,
verified by probe (one injected install command =>
"UNDETERMINED delayed-trigger allocators: 1 install command(s) ...", nothing
written, exit 1).

MECHANISM. `scripts/lib/trigger-firing.jq` holds the SINGLE definition of the
derivation; both the pristine-regeneration path (`migrate-dump-fixture.sh`) and
the in-place path (`stamp-fixture-firing.sh`) load that one file, so neither can
certify its own copy of the recipe.

WHY IN PLACE RATHER THAN A PRISTINE REGENERATION for these six. Regeneration is
the stronger provenance and is preferred where it applies, but it does not apply
here: measured, `dina_conqueror_4p` and `witherbloom_sprout_lumaret_simple_4p`
differ from their pristine regeneration in exactly one object each (Priest of
Forgotten Gods' `abilities`/`base_abilities` AST), because the committed fixture
carries a LATER parser state than the capture. Regenerating them would silently
REVERT that. Stamping in place is additive and cannot revert anything.

ALL THREE control arms are enforced, and the stamper refuses to write if any
fails:
  arm 1 NO_COLLATERAL   the stamped artifact minus the five stamped keys is
                        BYTE-IDENTICAL to what was committed, so nothing but the
                        carriers moved.
  arm 2 CARRIERS_ADDED  got == need && got > 0, keyed on CARRIER COUNT rather
                        than on byte difference. An earlier revision of this arm
                        keyed on bytes and reported a false pass for zero-carrier
                        fixtures, where gzip/jq re-serialization alone changes
                        bytes without stamping anything. Fixtures needing zero
                        carriers are now SKIPped outright.
  arm 3 ALLOCATORS_CANONICAL
                        both allocators exist and are >= 1 — i.e. above the
                        value the engine's own coherence validator rejects.

Also fixes a pre-existing defect in `migrate-dump-fixture.sh` that the corpus
sweep exposed: the `target_slots` stage used an unguarded `|=`, which aborts
with "Cannot iterate over null" on a dump paused at a beat with no target
prompt. The script was therefore only ever usable on 2 of the 6 dumps. The stage
is now guarded, so ONE recipe covers the whole corpus. The `gameState`-envelope
guard in the jq library is the same class of fix, caught by the control before
any write: without it, `.gameState |= ...` would have CREATED a `gameState` key
on the four `turn_number`-enveloped dumps, i.e. corrupted them.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…nto phase-rs#6842

The census tripwire pins the exact source coordinates of every CR 603.5 prompt
producer so that a SIXTH producer is a counted event rather than a silent one.
Rebasing this branch onto upstream phase-rs#6842 (8121fd1) moved five of those
coordinates, and the row fired.

ADJUDICATED, NOT RELAXED. The row fired on COORDINATES, not on population. The
producer count is still 5, not 6:

  game/effects/mod.rs                 :5896 :5973 :8927  =>  :5918 :5995 :8949
  game/engine.rs                      :10500                =>  :10589
  game/effects/scoped_library_search.rs :452                =>  :452  (UNMOVED)

`mod.rs` is a uniform +22 shift and `engine.rs` a +89 shift — the signature of
lines inserted ABOVE each site, not of a new producer. That
`scoped_library_search.rs:452` did NOT move is itself evidence the SET did not
change: a genuinely new producer would not leave an untouched file's coordinate
fixed while shifting the others by a constant.

The producer TEXT at all five sites was verified byte-identical between the
pre-rebase tree (`chain3-prefold-backup`, dbc81821d) at the old coordinates and
this tree at the new ones. So the expectation array is re-anchored to the new
coordinates and the assertion keeps its full strength: a sixth producer still
fails this row.

Correcting my own earlier report for the record: I previously stated that
upstream had ADDED a CR 603.5 producer. That was wrong — the count never left
5, and the failure was purely positional. The correction is carried in the
tripwire's own doc block so the next reader of this row does not inherit the
mistake.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
Upstream phase-rs#6842 (8121fd1) made a `TriggerFiring` carrier MANDATORY on every
persisted triggered record and fails CLOSED without one. The six 4p dump
fixtures this branch drives were captured before that commit, so on the rebased
base they no longer decode at all: 41 of the 44 red rows were production-decoder
rejections naming the three carriers (`active legacy pending trigger has no
firing discriminator`, `triggered stack entry has no firing carrier`,
`resolving triggered entry has no firing carrier`).

The carrier cannot be RECOVERED, only DERIVED. The read-only pristine root
(`combofb-dumps-pristine/`, captured 2026-07-22/25) predates phase-rs#6842 too and
records zero firing carriers, so re-deriving from pristine cannot mint a value
that was never captured.

`TriggerFiring::UnknownLegacy` is not an escape hatch, and this was measured
rather than assumed: `validate_firing` (7 call sites, types/game_state.rs
:7688/:7691/:7700/:7721/:7732/:7749/:7779) returns
`Err("... has no canonical trigger firing discriminator")` for it. It is the
field-absent marker (`skip_serializing_if`) and the redaction default, never a
legal persisted value.

THE DISCRIMINANT (CR 603.1 ordinary vs CR 603.7a delayed), applied per record:
  Ordinary <= the fired trigger's definition is present on its SOURCE OBJECT's
              own `trigger_definitions`/`base_trigger_definitions`, matched by
              exact `description`. A printed or granted triggered ability of a
              permanent is an ordinary triggered ability.
  Delayed  <= the trigger has an install receipt in `delayed_triggers`.
Anything else ABORTS by name. There is deliberately no fallback stamp: a wrong
carrier silently re-classifies a CR 603.7 firing identity, which is precisely
the inference upstream refuses to make.

Measured, per fixture — carriers stamped, and `delayed_triggers` length:

| fixture | carriers | delayed_triggers | sha256 (stamped) |
|---|---|---|---|
| dellian_emblem_conqueror_4p | 154 | 0 | 3f8b06eb7986de10 |
| dina_conqueror_4p | 7 | 0 | 773e902daccf7199 |
| fantastic_four_bounded_loop_4p | 3 | 0 | 89ff377b53b7bd55 |
| tenacity_exquisite_blood_4p | 6 | 0 | a296ab6e37129ca3 |
| witherbloom_sprout_lumaret_4p | 1 | 0 | 71efd26867e9fc57 |
| witherbloom_sprout_lumaret_simple_4p | 1 | 0 | 607fbe632e759692 |

172 carriers total, ALL `Ordinary`, ZERO undetermined. Every fixture records
`delayed_triggers: []` and no install journal, so `Delayed(Some(prov))` could
not have validated regardless — `validate_firing` demands a registered install
root. Every one of the 172 was classified from its own record's source object,
never defaulted; the per-record witness table (carrier, source id, card name) is
in the PR body.

Five resolving entries elsewhere in the corpus are NOT migration targets and are
deliberately left unstamped: `combo_infinite_pile_4p_untapped_precast`,
`kilo_freed_relic_pentad_4p`, `sprout_witherbloom_realistic_lands_4p`,
`basalt_power_artifact_infinite_colorless`, `combo_infinite_pile_4p_offer`.
Each `kind.type` is `Spell` or `ActivatedAbility`, not `TriggeredAbility`, and
the validator's `(Some(_), None) => {}` arm accepts a non-triggered resolving
entry with no carrier. An earlier classifier of mine flagged these as
UNDETERMINED; reading each entry's `kind.type` showed the flag was wrong.

SECOND FIELD CLASS — the CR 603.7 delayed-trigger ALLOCATORS. Restoring the
dumps exposed a further phase-rs#6842 change that the decode failures had been masking:
`next_delayed_trigger_token` carries `#[serde(default)]`, so a pre-phase-rs#6842 dump
that omits it restores as 0 through a bare `GameState` decode, while the
production `PersistedGameState` path runs a load-time repair
(`next = max(existing // 1, max_used_token + 1)`) and restores 1. The two
decoders therefore disagree on a legacy dump, and 0 is invalid on its face:
`validate_trigger_firing_coherence` rejects
`next_delayed_trigger_token <= max_token`, which is 0 when there are no install
roots.

This surfaced as the R8 state-neutrality arm of
`migrated_dump_decodes_through_both_decoders_and_unmigrated_through_neither`
reporting `differing paths: ["state.next_delayed_trigger_token"]`. That arm is
NOT relaxed. The fixtures are stamped with the repaired value instead, which
makes them look like a modern capture, keeps both decoders in agreement, and
survives the eventual deletion of the load-time shim.

Measured, all six fixtures: field absent, `delayed_triggers: 0`, and ZERO
`DelayedTriggerInstall` commands across 158/30/2/516/50/5094 journal entries. So
both used-token sets are empty and the formula collapses to `max(1, 1) = 1`. The
scan is not vacuous: injecting one synthetic install command into a fixture makes
the same selector report 1 instead of 0.

The GENERAL derivation of the used-token set is deliberately NOT reimplemented
in jq — it walks `resolved_rules_journal` install commands and `delayed_triggers`
provenance with reuse and nonzero checks, and re-deriving it here would repeat
exactly the mistake this script's sibling refuses to make for `EffectKind`. Only
the collapsed no-install-roots case is stamped; anything else ABORTS BY NAME,
verified by probe (one injected install command =>
"UNDETERMINED delayed-trigger allocators: 1 install command(s) ...", nothing
written, exit 1).

MECHANISM. `scripts/lib/trigger-firing.jq` holds the SINGLE definition of the
derivation; both the pristine-regeneration path (`migrate-dump-fixture.sh`) and
the in-place path (`stamp-fixture-firing.sh`) load that one file, so neither can
certify its own copy of the recipe.

WHY IN PLACE RATHER THAN A PRISTINE REGENERATION for these six. Regeneration is
the stronger provenance and is preferred where it applies, but it does not apply
here: measured, `dina_conqueror_4p` and `witherbloom_sprout_lumaret_simple_4p`
differ from their pristine regeneration in exactly one object each (Priest of
Forgotten Gods' `abilities`/`base_abilities` AST), because the committed fixture
carries a LATER parser state than the capture. Regenerating them would silently
REVERT that. Stamping in place is additive and cannot revert anything.

ALL THREE control arms are enforced, and the stamper refuses to write if any
fails:
  arm 1 NO_COLLATERAL   the stamped artifact minus the five stamped keys is
                        BYTE-IDENTICAL to what was committed, so nothing but the
                        carriers moved.
  arm 2 CARRIERS_ADDED  got == need && got > 0, keyed on CARRIER COUNT rather
                        than on byte difference. An earlier revision of this arm
                        keyed on bytes and reported a false pass for zero-carrier
                        fixtures, where gzip/jq re-serialization alone changes
                        bytes without stamping anything. Fixtures needing zero
                        carriers are now SKIPped outright.
  arm 3 ALLOCATORS_CANONICAL
                        both allocators exist and are >= 1 — i.e. above the
                        value the engine's own coherence validator rejects.

Also fixes a pre-existing defect in `migrate-dump-fixture.sh` that the corpus
sweep exposed: the `target_slots` stage used an unguarded `|=`, which aborts
with "Cannot iterate over null" on a dump paused at a beat with no target
prompt. The script was therefore only ever usable on 2 of the 6 dumps. The stage
is now guarded, so ONE recipe covers the whole corpus. The `gameState`-envelope
guard in the jq library is the same class of fix, caught by the control before
any write: without it, `.gameState |= ...` would have CREATED a `gameState` key
on the four `turn_number`-enveloped dumps, i.e. corrupted them.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…nto phase-rs#6842

The census tripwire pins the exact source coordinates of every CR 603.5 prompt
producer so that a SIXTH producer is a counted event rather than a silent one.
Rebasing this branch onto upstream phase-rs#6842 (8121fd1) moved five of those
coordinates, and the row fired.

ADJUDICATED, NOT RELAXED. The row fired on COORDINATES, not on population. The
producer count is still 5, not 6:

  game/effects/mod.rs                 :5896 :5973 :8927  =>  :5918 :5995 :8949
  game/engine.rs                      :10500                =>  :10589
  game/effects/scoped_library_search.rs :452                =>  :452  (UNMOVED)

`mod.rs` is a uniform +22 shift and `engine.rs` a +89 shift — the signature of
lines inserted ABOVE each site, not of a new producer. That
`scoped_library_search.rs:452` did NOT move is itself evidence the SET did not
change: a genuinely new producer would not leave an untouched file's coordinate
fixed while shifting the others by a constant.

The producer TEXT at all five sites was verified byte-identical between the
pre-rebase tree (`chain3-prefold-backup`, dbc81821d) at the old coordinates and
this tree at the new ones. So the expectation array is re-anchored to the new
coordinates and the assertion keeps its full strength: a sixth producer still
fails this row.

Correcting my own earlier report for the record: I previously stated that
upstream had ADDED a CR 603.5 producer. That was wrong — the count never left
5, and the failure was purely positional. The correction is carried in the
tripwire's own doc block so the next reader of this row does not inherit the
mistake.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 3, 2026
Upstream phase-rs#6842 (8121fd1) made a `TriggerFiring` carrier MANDATORY on every
persisted triggered record and fails CLOSED without one. The six 4p dump
fixtures this branch drives were captured before that commit, so on the rebased
base they no longer decode at all: 41 of the 44 red rows were production-decoder
rejections naming the three carriers (`active legacy pending trigger has no
firing discriminator`, `triggered stack entry has no firing carrier`,
`resolving triggered entry has no firing carrier`).

The carrier cannot be RECOVERED, only DERIVED. The read-only pristine root
(`combofb-dumps-pristine/`, captured 2026-07-22/25) predates phase-rs#6842 too and
records zero firing carriers, so re-deriving from pristine cannot mint a value
that was never captured.

`TriggerFiring::UnknownLegacy` is not an escape hatch, and this was measured
rather than assumed: `validate_firing` (7 call sites, types/game_state.rs
:7688/:7691/:7700/:7721/:7732/:7749/:7779) returns
`Err("... has no canonical trigger firing discriminator")` for it. It is the
field-absent marker (`skip_serializing_if`) and the redaction default, never a
legal persisted value.

THE DISCRIMINANT (CR 603.1 ordinary vs CR 603.7a delayed), applied per record:
  Ordinary <= the fired trigger's definition is present on its SOURCE OBJECT's
              own `trigger_definitions`/`base_trigger_definitions`, matched by
              exact `description`. A printed or granted triggered ability of a
              permanent is an ordinary triggered ability.
  Delayed  <= the trigger has an install receipt in `delayed_triggers`.
Anything else ABORTS by name. There is deliberately no fallback stamp: a wrong
carrier silently re-classifies a CR 603.7 firing identity, which is precisely
the inference upstream refuses to make.

Measured, per fixture — carriers stamped, and `delayed_triggers` length:

| fixture | carriers | delayed_triggers | sha256 (stamped) |
|---|---|---|---|
| dellian_emblem_conqueror_4p | 154 | 0 | 3f8b06eb7986de10 |
| dina_conqueror_4p | 7 | 0 | 773e902daccf7199 |
| fantastic_four_bounded_loop_4p | 3 | 0 | 89ff377b53b7bd55 |
| tenacity_exquisite_blood_4p | 6 | 0 | a296ab6e37129ca3 |
| witherbloom_sprout_lumaret_4p | 1 | 0 | 71efd26867e9fc57 |
| witherbloom_sprout_lumaret_simple_4p | 1 | 0 | 607fbe632e759692 |

172 carriers total, ALL `Ordinary`, ZERO undetermined. Every fixture records
`delayed_triggers: []` and no install journal, so `Delayed(Some(prov))` could
not have validated regardless — `validate_firing` demands a registered install
root. Every one of the 172 was classified from its own record's source object,
never defaulted; the per-record witness table (carrier, source id, card name) is
in the PR body.

Five resolving entries elsewhere in the corpus are NOT migration targets and are
deliberately left unstamped: `combo_infinite_pile_4p_untapped_precast`,
`kilo_freed_relic_pentad_4p`, `sprout_witherbloom_realistic_lands_4p`,
`basalt_power_artifact_infinite_colorless`, `combo_infinite_pile_4p_offer`.
Each `kind.type` is `Spell` or `ActivatedAbility`, not `TriggeredAbility`, and
the validator's `(Some(_), None) => {}` arm accepts a non-triggered resolving
entry with no carrier. An earlier classifier of mine flagged these as
UNDETERMINED; reading each entry's `kind.type` showed the flag was wrong.

SECOND FIELD CLASS — the CR 603.7 delayed-trigger ALLOCATORS. Restoring the
dumps exposed a further phase-rs#6842 change that the decode failures had been masking:
`next_delayed_trigger_token` carries `#[serde(default)]`, so a pre-phase-rs#6842 dump
that omits it restores as 0 through a bare `GameState` decode, while the
production `PersistedGameState` path runs a load-time repair
(`next = max(existing // 1, max_used_token + 1)`) and restores 1. The two
decoders therefore disagree on a legacy dump, and 0 is invalid on its face:
`validate_trigger_firing_coherence` rejects
`next_delayed_trigger_token <= max_token`, which is 0 when there are no install
roots.

This surfaced as the R8 state-neutrality arm of
`migrated_dump_decodes_through_both_decoders_and_unmigrated_through_neither`
reporting `differing paths: ["state.next_delayed_trigger_token"]`. That arm is
NOT relaxed. The fixtures are stamped with the repaired value instead, which
makes them look like a modern capture, keeps both decoders in agreement, and
survives the eventual deletion of the load-time shim.

Measured, all six fixtures: field absent, `delayed_triggers: 0`, and ZERO
`DelayedTriggerInstall` commands across 158/30/2/516/50/5094 journal entries. So
both used-token sets are empty and the formula collapses to `max(1, 1) = 1`. The
scan is not vacuous: injecting one synthetic install command into a fixture makes
the same selector report 1 instead of 0.

The GENERAL derivation of the used-token set is deliberately NOT reimplemented
in jq — it walks `resolved_rules_journal` install commands and `delayed_triggers`
provenance with reuse and nonzero checks, and re-deriving it here would repeat
exactly the mistake this script's sibling refuses to make for `EffectKind`. Only
the collapsed no-install-roots case is stamped; anything else ABORTS BY NAME,
verified by probe (one injected install command =>
"UNDETERMINED delayed-trigger allocators: 1 install command(s) ...", nothing
written, exit 1).

MECHANISM. `scripts/lib/trigger-firing.jq` holds the SINGLE definition of the
derivation; both the pristine-regeneration path (`migrate-dump-fixture.sh`) and
the in-place path (`stamp-fixture-firing.sh`) load that one file, so neither can
certify its own copy of the recipe.

WHY IN PLACE RATHER THAN A PRISTINE REGENERATION for these six. Regeneration is
the stronger provenance and is preferred where it applies, but it does not apply
here: measured, `dina_conqueror_4p` and `witherbloom_sprout_lumaret_simple_4p`
differ from their pristine regeneration in exactly one object each (Priest of
Forgotten Gods' `abilities`/`base_abilities` AST), because the committed fixture
carries a LATER parser state than the capture. Regenerating them would silently
REVERT that. Stamping in place is additive and cannot revert anything.

ALL THREE control arms are enforced, and the stamper refuses to write if any
fails:
  arm 1 NO_COLLATERAL   the stamped artifact minus the five stamped keys is
                        BYTE-IDENTICAL to what was committed, so nothing but the
                        carriers moved.
  arm 2 CARRIERS_ADDED  got == need && got > 0, keyed on CARRIER COUNT rather
                        than on byte difference. An earlier revision of this arm
                        keyed on bytes and reported a false pass for zero-carrier
                        fixtures, where gzip/jq re-serialization alone changes
                        bytes without stamping anything. Fixtures needing zero
                        carriers are now SKIPped outright.
  arm 3 ALLOCATORS_CANONICAL
                        both allocators exist and are >= 1 — i.e. above the
                        value the engine's own coherence validator rejects.

Also fixes a pre-existing defect in `migrate-dump-fixture.sh` that the corpus
sweep exposed: the `target_slots` stage used an unguarded `|=`, which aborts
with "Cannot iterate over null" on a dump paused at a beat with no target
prompt. The script was therefore only ever usable on 2 of the 6 dumps. The stage
is now guarded, so ONE recipe covers the whole corpus. The `gameState`-envelope
guard in the jq library is the same class of fix, caught by the control before
any write: without it, `.gameState |= ...` would have CREATED a `gameState` key
on the four `turn_number`-enveloped dumps, i.e. corrupted them.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 3, 2026
…nto phase-rs#6842

The census tripwire pins the exact source coordinates of every CR 603.5 prompt
producer so that a SIXTH producer is a counted event rather than a silent one.
Rebasing this branch onto upstream phase-rs#6842 (8121fd1) moved five of those
coordinates, and the row fired.

ADJUDICATED, NOT RELAXED. The row fired on COORDINATES, not on population. The
producer count is still 5, not 6:

  game/effects/mod.rs                 :5896 :5973 :8927  =>  :5918 :5995 :8949
  game/engine.rs                      :10500                =>  :10589
  game/effects/scoped_library_search.rs :452                =>  :452  (UNMOVED)

`mod.rs` is a uniform +22 shift and `engine.rs` a +89 shift — the signature of
lines inserted ABOVE each site, not of a new producer. That
`scoped_library_search.rs:452` did NOT move is itself evidence the SET did not
change: a genuinely new producer would not leave an untouched file's coordinate
fixed while shifting the others by a constant.

The producer TEXT at all five sites was verified byte-identical between the
pre-rebase tree (`chain3-prefold-backup`, dbc81821d) at the old coordinates and
this tree at the new ones. So the expectation array is re-anchored to the new
coordinates and the assertion keeps its full strength: a sixth producer still
fails this row.

Correcting my own earlier report for the record: I previously stated that
upstream had ADDED a CR 603.5 producer. That was wrong — the count never left
5, and the failure was purely positional. The correction is carried in the
tripwire's own doc block so the next reader of this row does not inherit the
mistake.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 3, 2026
…firewall, and measured 4p rows (combo-fb phases 5a-5d, chain 3) (phase-rs#6886)

* feat(engine): pin per-iteration decision slots for bounded-loop shortcut

A bounded cycle can only be fast-forwarded when every player choice it repeats
is forced. Publish the pinned slots for the one class where that provably
holds — a "target opponent" triggered ability — and let the loop-window scope
discharge exactly those, and only those.

The published legal set is MOVED from the announcement authority
(`build_target_slots`, CR 601.2c: the ability's controller announces), never
re-derived from the head effect's target filter. Re-deriving it published
[Player(1), Player(2)] where the builder said [Player(0), Player(1), Player(2)]
on a chained "target player" sub-ability — measured, not hypothetical. The head
filter is now a shape gate only, enforced by a `bool` return rather than by
comment, so re-deriving it is unrepresentable.

The relief returns a residual verdict instead of a blanket `continue`, so it
discharges only what the pin specifies and re-arms the life guard; and the
re-check delegates to the same authority that minted the value, so the two
cannot drift.

CR 601.2c / CR 603.3d / CR 115.2 (each grepped in docs/MagicCompRules.txt)

Tests: 22036 passed / 0 failed / 15 ignored (`cargo test -p engine`; the 15 is
8 #[ignore] + 7 doc-test fences. `cargo nextest run -p engine` reports 22036
passed / 8 skipped — it does not run doc-tests, so cite the runner).
clippy --workspace --all-targets -D warnings: clean.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): bounded CR 732.2a cycle fast-forward for a multiplayer drain

A draining loop never returns the board to a prior state, so board-recurrence
alone cannot certify it. Add a second, board-blind basis: a periodic resource
signature derived from the retained ring, which certifies a period only after
seeing it twice, and publish a bounded shortcut whose iteration cap is the
CR 704.5a elimination minimum computed from the live board.

The board-blind basis needs a discriminant the board-based one gets for free.
Without one it accepts the game's own turn structure — two players each drawing
once per turn is a genuine repeating resource signature, and it minted a
shortcut offer on a board with no loop at all. CR 732.2a permits a shortcut to
cross turns, so the fix is scoped to the board-blind basis only: its certifying
window must be turn-position invariant, expressed through the same
window_scope_from_cover_frames authority the growing-class firewall already
uses. Zero new predicates, types, or fields.

Basis attribution is by discriminating probe, never by frames_per_period:
basis A publishes 1 unconditionally and the board-blind basis at k==1 publishes
the identical value, so equality discriminates nothing. That inference had
already mislabeled a real four-player drain dump as board-recurrent; it is in
fact the board-blind basis, and it is this feature's real-dump control.

On a growing cascade the two bases are separated by whether the board carries a
fire-time condition reading a projected resource axis -- not by which resources
move. Recorded at the dispatch site, since both shipped row docs had asserted a
resource-purity rule that measurement refuted on their own fixtures.

CR 732.2a / CR 704.5a / CR 703.1a / CR 601.2c (each grepped in
docs/MagicCompRules.txt)

Tests: 22046 passed / 0 failed / 15 ignored (cargo test -p engine); cargo
nextest run -p engine reports 22046 passed / 8 skipped -- it does not run
doc-tests, so cite the runner. clippy --workspace --all-targets -D warnings
clean.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): a mandatory draw is not a resolution-time choice

The bounded-shortcut offer requires every stack entry's resolution-time
choices to be specified. That verdict came from an allow-list of exactly two
Effect variants -- GainLife and LoseLife -- out of roughly two hundred, with
everything else falling to a fail-closed MayPrompt. The exclusions were never
assessed and found choice-bearing; they were simply never enumerated, so a
draining loop whose stack held a draw trigger could never be fast-forwarded.

Keyed on the MECHANISM, never the variant name: a mandatory draw whose only
prompt route is its replacement environment. The same Effect::Draw is
genuinely choice-bearing when its ability is optional (CR 603.5), and stays
refused; a count of "up to" is a CR 608.2d resolution-time choice and is
excluded fail-closed, recursively, so a future QuantityExpr variant cannot
smuggle one past a wildcard.

The verdict is parameterized rather than given a sibling: FreeUnlessReplacements
carries the set of replacement classes its obligation covers, and chained
abilities union them. A FreeUnlessDrawReplacements sibling would have forced
all three consumers to re-derive that union. The parameterization is grounded,
not assumed: all three prompt seams live in the event-agnostic pipeline_loop
and none branches on event class.

Also corrects a premise this code documented and a real fixture falsifies.
The every-entry scan was justified by "in an exact-recurrence window every
entry re-announces each cycle"; the dellian dump is a growing-cascade window
over a frozen bottom prefix -- 107 entries with identical ids and indices
surviving 220 beats while the stack grew above them. dellian is named there as
the refutation only. No allow-list widening of any size unblocks it, and
nothing here claims otherwise.

CR 121.1 / CR 616.1 / CR 608.2d / CR 603.5 / CR 702.52a / CR 704.5b (each
grepped in docs/MagicCompRules.txt; 702.52a is dredge -- 702.51a, written from
memory first, is convoke)

Acceptance: bloodloop at 3 and 4 players goes 0 offers -> 1, with
UnspecifiedChoiceWindow 34 -> 0 attributing the flip to that gate; the 2p offer
moves beat 31 -> 29. All three beats pinned. Five revert probes, each flipping
its own assertion.

Tests: 22048 passed / 0 failed / 15 ignored (cargo test -p engine); cargo
nextest run -p engine reports 22048 passed / 8 skipped -- it does not run
doc-tests, so cite the runner. clippy --workspace --all-targets -D warnings
clean.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): delimit a bounded CR 732.2a cycle by its published per-period frame span

A basis-B certificate — what `ring_delta_signature` mints, and the class the
bounded offer widened to — certifies a periodic DELTA, not a recurring board.
`materialize_fixed_shortcut`'s 'cycles loop only advanced on board recurrence,
so neither recurrence predicate could ever fire and the declared `n` was
structurally inert: the drive could only end at the beat cap (committing zero)
or by crossing lethal. Measured on the production accept path at c6d834040,
`Fixed(1)` and `Fixed(3)` were byte-identical — bloodloop 3p/4p wiped the whole
table at either count, dina 4p committed nothing at either.

`PeriodicDelta::frames_per_period` was written, cloned onto the proposal, and
read by nothing. It is the missing delimiter, and its own doc already specified
the check nobody implemented ('so a bounded drive can check that each committed
cycle actually conformed'). Both now exist:

* `drive_one_shortcut_cycle` takes the published span and completes a cycle once
  that many retained ring frames have been recorded. Frames are counted the same
  way they are minted — the engine's single `record_loop_detect_sample` call site
  is inside `pass_priority_once_with_pipeline`, the function the drive steps — and
  detected by Arc identity of the ring's back, since the ring evicts at its cap.
* `materialize_fixed_shortcut` drops any committed cycle whose measured resource
  delta differs from the published one. CR 704.5a: `elimination_bounds` divided
  the headroom by that delta, so a divergent cycle invalidates the agreed bound.

`per_cycle: None` (every pre-bounded offer) keeps board recurrence as the sole
delimiter and skips the conformance check, so those drives are unchanged.

Basis A's `frames_per_period` was a hardcoded `1` and is measured WRONG: the
subset-lethal DRAIN_CLERIC/BLOOD_SIPPER fixture's repetition spans TWO frames.
It is now derived from the certifying prior's ring index. Under the hardcode
that fixture's accepted drive committed nothing at all (the new conformance check
catching the half-period). The shipped `frames_per_period == 1` assertion was a
self-ratifying oracle — it compared a literal against a constant no game state
could influence — and is corrected to the measured 2 with that mechanism recorded.

AFTER, same production path: bloodloop3 n=1 [20,17,17]->[20,16,16], n=3 ->[20,14,14],
n=16 (the bound) ->[20,1,1]; dina 4p n=1 ->[50,34,30,35], n=30 (the bound)
->[79,5,1,6]. Zero eliminations at every n within bound.

New rows: the n-scaling acceptance property on all three fixtures; the matched
stop-short pair at the CR 704.5a boundary; the conformance drop; a named negative
row for each of the three bounded-offer conjuncts that refused zero times; and the
AI's bounded-declare candidate generated, applied and driven.

Doc corrections, each re-measured rather than reasoned: the dina row's named
step-(7) revert-probe genuinely does not flip (106/0 green under `1..=MAX`); the
dellian frozen bottom prefix is 151 entries over 220 beats, not 140 or 107; the AI
policy's 'every n within max_iterations eliminates nobody' premise was false when
it shipped and is now true and re-measured.

Assisted-by: ClaudeCode:claude-opus-4.8

* test(engine): cover the partial-crossing arm and the basis-A span at drive level

Fix round 2 on the `review-impl` findings. No production behaviour changes: every
`engine.rs` edit is a comment or lives inside `#[cfg(test)] mod
bounded_offer_conjunct_tests`.

MED-1. The required cross-lethal stop-short row was demonstrated only on bloodloop3,
whose two opponents sit at equal life (17/17) and therefore cross 0 on the SAME cycle
— a symmetric fixture cannot witness an asymmetric outcome. The arm real multiplayer
boards take had no fixture, and the mirror's doc claim ("the eliminated set is exactly
the seats the published period drains") is false on it. The two arms are asymmetric:

  total wipe    every remaining opponent crosses together => WaitingFor::GameOver
                => CycleOutcome::CrossLethal: the crossing cycle COMMITS, game ends,
                   eliminated == victims
  partial       one seat crosses while >= 2 survive => no GameOver
                => CycleOutcome::Abort: the crossing cycle is ROLLED BACK WHOLE,
                   eliminated == [] (by rollback, not because nobody crossed)

Both are out of contract for a legitimately-derived bound — `elimination_bounds`
reserves `life - 1` of CR 704.5a headroom — so each is reachable only under a doctored
one. `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle` asserts the measured
partial behaviour on the dina 4p dump: honest bound 30, first crossings 31/35/36, so
doctoring the bound to 31/34/40 commits exactly 30 periods, eliminates nobody and hands
back `Priority{P0}`. The Abort is not "fixed": rolling the out-of-contract cycle back is
conservative and correct, since the remaining repetitions were bounded by a delta the
board stops moving once a drain target leaves. The mirror's claim is now scoped per arm
and its arm-a doctoring is recorded — and asserted — as the no-op it is on that fixture.

MED-2. Reverting `frames_per_period: span as u32` to the old hardcoded `1` flipped
exactly one row, and that row asserts the PUBLISHED number, never accepting a `Fixed(n)`
— so the basis-A half of the delimiter shipped on a single published-value assertion.
`basis_a_bounded_fixed_count_commits_exactly_n_periods` declares and drives `Fixed(1)`,
`Fixed(2)` and `Fixed(3)` through `apply()` on the subset-lethal (basis-A, k == 2)
fixture and measures `n * delta` committed. Under the hardcode it commits `{0, 0, 0}` at
every n — the conformance check dropping the half-periods a too-small k produces. The
width tripwire is placed LAST on purpose: with it first, the hardcode probe failed there
and the drive assertions were never reached, which would have made the row a second copy
of the one it exists to back. Measured: the hardcode revert now flips 2 rows of 85, and
the flip it reports is "the drive committed nothing".

The `span >= 1` fail-closed guard gains
`a_zero_span_certifying_pair_never_publishes_a_zero_width_period`, on a new `drain_ring`
fixture. `mill_ring` could not serve: library size is BOARD, so its frames are
board-unequal, basis A refuses them outright, and every mill-ring row in that module is
really exercising basis B. Life is projected, so a drain ring's frames are board-equal
and reach the basis-A walk. Reach-guards assert the span-0 pair satisfies both halves of
the disjunct, so the guard is provably the refuser; deleting it publishes 0.

Doc corrections, each re-measured. Six sites claimed "basis A publishes 1
unconditionally" — false since the previous commit made it a measured span, and one of
them was carrying a basis attribution that inference no longer supports. Counts now name
the runner AND the filter (`cargo test -p engine --test integration -- loop_shortcut::`,
module filter, 4090 filtered out); the one bare count with neither recorded is deleted
rather than re-dressed. Revert-probes run: delete the frame delimiter => 5 of 85 fail
(the basis-A row does NOT, because its board recurs and delimits itself); delete the
conformance check => 1 of 85 fails and the partial-crossing row stays green, which is
what establishes its stop is the Abort rather than a conformance drop.

Assisted-by: ClaudeCode:claude-opus-5

* docs(engine): the aborted cycle rolls back alone, not the whole drive

Doc-only. Corrects the arm-asymmetry wording introduced in the previous commit,
which read as if a partial crossing discarded the entire drive. It does not:
`materialize_fixed_shortcut`'s `break 'cycles` falls through to `*state =
committed`, the last WHOLE cycle — so the out-of-contract cycle is refused
atomically while every prior conforming cycle stays committed. The measured
shape already said so (dina 4p, honest bound 30: a doctored `n` at or past the
first crossing commits 30 periods and refuses the 31st), and the row's
assertions were already bound to `first_crossing - 1` rather than to zero.

  total wipe        every remaining opponent crosses 0 on the same cycle
                    => WaitingFor::GameOver => CycleOutcome::CrossLethal:
                    the crossing cycle COMMITS, the game ends
  partial crossing  one seat crosses 0 while >= 2 players survive => no
                    GameOver => CycleOutcome::Abort: the crossing cycle rolls
                    back WHOLE; prior conforming cycles STAY COMMITTED;
                    priority handback

The property this buys is named where it is implemented: no half-applied
period, ever — refuse the out-of-contract cycle atomically, keep the conforming
prefix. The per-arm eliminated-set split is unchanged and stays two distinct
facts: on the GameOver arm the eliminated set is exactly the victims; on the
Abort arm it is empty because the crossing cycle was refused, not because
nobody crossed.

Assisted-by: ClaudeCode:claude-opus-5

* docs(engine): correct four doc-accuracy claims and re-cite CR 101.2 in the bounded-offer rows

Fix round 3 of the combo-detector player-feedback review loop. Doc-only:
zero production or test-behaviour change; all five findings were LOW.

- LOW-1: the flip-count sentence mixed epochs (numerator pre-commit, denominator
  post-commit). Now states 1-of-83 as measured on the pre-row tree and 2-of-85 on
  this one, with the runner and filter named.
- LOW-2: CR 119.8 governs life exchanges, redistribution, and pay-life costs --
  not a "Your life total can't change." override. Re-cited to CR 101.2, which is
  the engine's own convention and what the sibling fixture doc already names.
- LOW-3: a bare "(4167)" suite count with no runner and no filter is deleted
  rather than re-dressed, mirroring the same removal in the unit module's doc.
  Swept both files; it was the last one.
- LOW-4: the basis-A drive row now scopes itself as synthetic-fixture-only --
  every real 4p dump in the file certifies on basis B.
- LOW-5: "basis A refuses a mill ring outright" rested on evidence measuring only
  the equal disjunct. Measured the cover disjunct too: cover == false at all three
  ring indices, so the claim holds -- and the probe corrects the reason at the
  oldest frame, which is board-EQUAL and refused by net_progress_for instead.

Verified: cargo fmt --all; cargo clippy --all-targets -- -D warnings (exit 0);
cargo test -p engine --lib -- game::engine::bounded_offer_conjunct_tests:: (4
passed / 0 failed, 17870 filtered out); cargo test -p engine --test integration --
loop_shortcut:: (85 passed / 0 failed, 4090 filtered out).

Assisted-by: ClaudeCode:claude-opus-4.8

* docs(engine): fix the CLAIM, not the site -- four sibling doc corrections

Fix round 4 of the combo-detector player-feedback review loop. Doc-only:
zero non-comment lines on either side of the diff; all four findings were LOW.
Every one was a SIBLING SITE that round 3's fix did not reach, or
self-contamination that round 3's fix introduced.

- LOW-1: drain_ring's doc -- the DEFINITIONAL site a reader reaches first --
  still carried the blanket "a mill ring's frames are board-UNEQUAL" that round 3
  corrected 160 lines later. Now states the per-index truth: board-unequal at
  every index EXCEPT the oldest, which pops zero cards, is board-EQUAL, and is
  refused by net_progress_for on its zero delta. The conclusion (basis A
  certifies nothing on a mill ring) is unchanged and still measured.
- LOW-2: "this is the only row that detects a back-door deletion of basis B"
  was measurably false and licensed exactly the overread the round-3 SCOPE note
  exists to prevent, from 870 lines earlier. Re-measured with the file's own
  prescribed attribution probe (ring_delta_signature -> None unconditionally),
  runner cargo test -p engine --test integration -- loop_shortcut:: : 74 passed /
  11 failed / 4090 filtered, against a clean 85 passed / 0 failed. ELEVEN rows
  flip; all ten others are now named so the claim is checkable.
- LOW-3: the file named three rules for one const -- CR 101.2 twice and a
  governing CR 119.8 once. The outlier survived the round that fixed its twin.
  Both life-loss-immune fixture docs now read "CR 101.2 ... cf. CR 119.8, which
  governs only life EXCHANGES, REDISTRIBUTION and pay-life COSTS". Swept both
  files: no governing 119.8 remains.
- LOW-4: round 3's own correction became the file's first in-comment test
  attribute, so its stated counting recipe (grep the attribute over this file)
  returned 86 while the runner reported 85 -- the fix for a counting error broke
  the count by containing the thing being counted. The denominator is now
  anchored to the runner, which is where the number came from, and the quoted
  literal is gone (grep and runner agree at 85 again).

Verified on the frozen final tree: cargo fmt --all; cargo clippy --all-targets
-- -D warnings (exit 0, zero warnings) -- the gate the round-4 reviewer honestly
did not run; cargo test -p engine --test integration -- loop_shortcut:: (85
passed / 0 failed, 4090 filtered out). Attribution probe reverted
byte-identically (md5 match) before any edit landed.

Assisted-by: ClaudeCode:claude-opus-5

* docs(engine): scope two universals in the basis-B control's doc to their measured sets

Fix round 5 of the combo-detector player-feedback review loop. Doc-only: 26 added
and 4 removed lines, all `///`; zero non-comment lines on either side. Both LOW
findings were universal quantifiers that had never been checked against the set
they quantify over -- one of them introduced by round 4's own replacement sentence.

- LOW-1: "each of those ten fails for a reason its own doc does not name" is false
  for the first row it names. Now NINE of ten, with dina called out as the
  exception: its CERTIFICATION BASIS note already documents this very probe as
  measurement (ii), down to the 400-beat cap and the `expect` firing. Measured by
  scanning all ten doc blocks for `ring_delta_signature`/`basis B` (dina 6 hits,
  the other nine 0), plus a second pass for probe-adjacent wording.

- LOW-2: annotating dina alone as "(the real 4p dump)" implied an exclusivity it
  does not have, and contradicted the plural "the file's real 4p dumps" 900 lines
  later. Dropped, and replaced by the measured split over the whole set: SIX of
  the eleven load the real dina_conqueror_4p capture, five are GameScenario builds,
  and bounded_fixed_count_commits_exactly_n_periods is the one MIXED row.
  Classified by resolving every fixture-loading call site to its enclosing test fn.

The count basis is stated in the doc as call-site resolution, NOT grep hit count,
because these sentences themselves add doc-comment hits for the names they list --
the self-contamination class this lane earned in round 4. Both measurement methods
were re-run after the edit and returned the same numbers.

Verification: cargo clippy --all-targets -- -D warnings, exit 0, zero warning or
error lines. cargo fmt --all clean.

Assisted-by: ClaudeCode:claude-opus-5

* docs(engine): point our repro commands at the renamed phase-engine package

Upstream 2b204dff5 (#6739) renamed the engine package to phase-engine, so
the six `cargo test -p engine` repro commands in these two files' doc
comments no longer resolve. Comment-only; no non-doc lines changed.

Upstream's own stale `-p engine` doc comments in eight other files are
left alone -- provenance-checked against upstream/main, they are not ours.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): route player choice through one legality authority (5c)

Every site that materializes a player choice or validates a player target now
goes through a single authority instead of ad hoc per-site filters, so a seat
that has left the game or phased out can no longer be offered or pinned.

- CR 102.1: eliminated players are not choosable or targetable.
- CR 702.26b: a phased-out permanent's controller is treated as absent for
  choice and target legality.
- CR 608.2b: pinned player targets are re-validated mid-drive; an unresolvable
  pin withdraws the offer rather than publishing an undeclarable point.
- CR 732.2a: a wire-encoded zero-repetition shortcut bound is rejected at the
  load seam, closing a window that let a zero-cycle proposal deserialize.

Also repairs the save-compat path: dump loaders now decode through the
production PersistedGameState decoder rather than bypassing it, and fixture
migration is re-runnable from the read-only pristine root via
scripts/migrate-dump-fixture.sh.

Assisted-by: ClaudeCode:claude-opus-5

* feat(engine): split LoopDetectSample into normalized and live ring halves (5d U0)

CR 104.4b: the normalized half stays the draw-detection comparand;
the live half preserves what normalization erases so later 5d steps
can evaluate period touches against real board state. Ring re-typed
VecDeque<Arc<LoopDetectSample>>, serde(skip) unchanged (zero
persistence surface), normalize_for_loop body untouched.

Assisted-by: ClaudeCode:claude-opus-5

* feat(engine): event-derived resolution obligation and prompt-cause partition (5d U1)

CR 616.1/614.1a: replacement-prompt causes derived from the proposed-event
stream (ReplacementPromptCauses bit-set over find_applicable_replacements);
29-variant wildcard-free event_is_accounted partition; probe_resolution
with the four Prompted arms; bind_resolution_scope extracted from
resolve_top (CR 608.2k); UpTo guards on all six promoted quantity arms;
forward-split verdict_memo::ProbeBudget (narrowed at U3).

Assisted-by: ClaudeCode:claude-opus-5

* feat(engine): shape-B mint conjuncts and declare-time owner firewall (5d U2)

CR 603.5/732.2a: entry_publishes_pin_slots gains the recipient,
stored-auto-choice, and mint-level cardinality conjuncts for both mint
shapes; declare-time owner firewall validates against
LoopShortcutOffer.proposer with apply_confirmed_shortcut re-validation;
firewall placement covers the empty-schema route (R28 a-double-prime
pins it). Known pre-existing defect disclosed, not fixed here:
ShortcutProposal.per_cycle PlayerId-keyed map fails JSON key
deserialization, making persisted bounded-shortcut saves unloadable.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): re-derive the loop-shortcut probe budget from the offering beat

Measured through `try_offer_bounded_cycle_shortcut_metered`, three real 4p dumps
driven through `apply()` at an unbounded cap:

  dina    beat 19  spent=13  asks=13  skips=0  ring=3  stack=10   OFFER
  dina    beat 45  spent=73                                        OFFER (post-fast-forward)
  dellian max      spent=107 asks=5           ring=16 stack=177    no offer in 200 beats
  f4      max      spent=0                                         no offer in 200 beats

R16(ii-a): demand at the corpus's acceptance beat is 13, so the shipped cap of 12
starved it by exactly one charge. PROBE_BUDGET 12 -> 26 (2x the measured demand;
the unexempted sweep measures 96-107, so an unexempted classification still
exhausts and still refuses fail-closed). R16(ii-b), recorded: the max spend across
ALL mintable beats is 107, at non-offering dellian beats where refusal is correct.

BOTH AXES at the offering beat, per the R33 escalation: basis = B
(`ResourceSignatureOnly`); the within-basis-A disjunct has NO VALUE — basis A
certified 0 times across all three dumps (129 basis-B certifications). Named-dump
caveat: dellian and F4 reached no offering beat in 200 beats under
`dump_drive_one_beat`, so the witness is dina.

Consequence for the cost table (§3 D4.3): the frozen exemption's speed-up applies
at dellian-shaped beats, which exist in-corpus but never offer under the shipped
driver. THE OFFERING BEAT PAYS THE FULL UNEXEMPTED SWEEP — `skips=0` there — so
the 854x headline no longer describes the beat this class actually offers on.

`MintMeter` gains `certification`, the only surface on which the certifying
disjunct is observable: both bases publish `frames_per_period`, so the published
`LoopCertificate` discriminates in neither direction.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): pin the frozen exemption to its certifying disjunct (5d U3, R33)

Six arms on the CR 732.2a bounded-offer frozen exemption, all on real-dump or
constructed-equality windows driven through `apply()`:
- (a)/(b)/(a'1)/(c1)/(c2) on the real dellian window — beat SEARCHED by
  construction requirements, landing on beat 14 / 152 frozen, independently
  agreeing with the plan's RF13-BEAT row; (a) bounded by an independent
  longest-common-prefix scan, never f(x)==f(x).
- (a'2) at the SELECTION site on a constructed equality-certified ring, with
  both reach-guards firing (non-empty BoardCovered frozen set;
  certification == Some(BoardEqualOnly)).
- (d) landed previously (060d0cf64).

Revert-probes, all run and restored byte-identical:
1. delete the pre-walk early return  => (b) flips, 152 frozen leak back
2. basis-B call site -> BoardCovered => (d) flips
3. delete step 4b (round-39 shape)   => (a'2) ALONE flips while a/b/a'1/c/(d)
   all still pass — the round-40-vs-round-39 discriminator this arm exists for.

R33(c2) keys to the PROBE_BUDGET constant (12->26 needed no edit); the 96-107
unexempted band is recorded, not asserted.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): budget-refusal and warmup-meter rows for the bounded offer (5d U3, R15/R16(v)/R20)

- R15: the real dina offer beat replayed at ProbeCap::Lowered(0) refuses with
  UnspecifiedChoiceWindow while Shipped offers; probe (delete probe_resolution's
  try_charge_one arm) FLIPPED — the zero-cap board offers at spent:0 asks:13,
  D=13 confirmed by a third independent instrument. Basis B certifies for free
  at any cap (no board predicate); the denial is conjunct (6)'s.
- R16(v): a ring-starved dellian beat at priority (stack > 2) yields
  NoCertification with (spent, denied, asks, scans) == (0,false,0,0).
  DISCLOSED LIMITATION in the test doc: MintMeter is snapshotted below the ring
  gate's early return, so a hoisted eager pass is invisible to this instrument —
  the observable half is pinned; the structural half rests on construction order.
- R20: 27 entries (PROBE_BUDGET + 1) with the last CHAINED so the bound is
  per-link; the same board offers at RaisedTwiceLinks, refuses at Shipped with
  denied at spent == PROBE_BUDGET, and a pre-charge NotAtPriority control leaves
  a clean meter. Probe run twice: run 1 masked by (i)'s spent >= entries clause
  (re-keyed to conjunct6_asks — what the gate examined, not what it charged);
  run 2 flips in the plan's direction (over-budget board OFFERS, asks:27 spent:0).
- Shared fixtures extracted (dina_driven_to_bounded_offer, replay_at_priority,
  equality_ring_with_stack); R33 arm (a'2) rewired to the helper and re-run green.

All probes restored byte-identical; resolution_prompt.rs carries zero diff.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): offer-writer census and carrying-frame rows (5d U3, R8/R14/R19/R29/R31/R32)

Purely additive test rows on top of 93dc52c02; six revert-probes run, flipped,
and restored byte-identical. Suite arithmetic pins the delta: lib 18144 -> 18149,
integration 4311 -> 4313.

Three plan contradictions disclosed, none silently absorbed:
- R8's ROUND-7 pre-change check is comment-blind: U2's doc comment at
  engine.rs:4168 makes a bare anchor measure 23/12. The census excludes comment
  lines (a comment writes no offer and consumes none), restoring the plan's
  22 production / 12 test and its per-file production multiset exactly.
- R29's stated (c) probe had no flipping site as written ((c1)/(c2) sit on the
  predicate, the probe edits an argument they never traverse); ADDED arm (c3)
  driving the same crossing through stack_choices_are_all_specified — the probe
  flips (c3) alone.
- R21 arm (a3) is FALSIFIED and not written: it demands frozen_skips > 0 at the
  offering beat, but the corpus's offering beat certifies through basis B with
  skips == 0 — the exact fact R33 arm (d) pins. A row contradicting a shipped
  green row does not ship.

CR anchors: 115.2, 601.2c, 603.3c, 603.5, 608.1, 732.2a — all re-read against
the code they describe.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): verdict-door totality, keying and proposer rows (5d U3, R22)

All five R22 conjuncts across four tests so each conjunct's revert-probe lands
on its own assertion: totality/frame/proposer (1)+(2)+(2'), the window-offset-
zero vacuity boundary (green under all four probes, never counted as coverage),
conjunct (3) foreign-frame refusal, conjunct (4) effective-key proposer.

Four probes run, flipped, restored byte-identical (0 deleted lines in the diff):
id-only memo key; unchecked frame_ix; hard-coded proposer; deleted relief
agreement guard. The fourth flipped only on run 2 — run 1 exposed a VACUOUS
relief negative: entry_publishes_pin_slots early-returns on
entry.controller != proposer (engine.rs:1670), so the B-bound container
answered None upstream of the guard under test. Fixed with a B-controlled
entry plus a positive reach-guard (under B's own pins the entry IS relieved);
the probe then flipped. CR 603.5 anchor re-verified.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): close the U3 remainder — R16, R17, R21(b)-family, R27(a2), Arc::as_ptr

Nine test-only rows, +919/-0, no production line touched.

MEASURED, through `try_offer_bounded_cycle_shortcut_metered` and through the
predicates the mint calls, on both TRACKED 4p dumps driven via `apply()`:

  dina    beat 19  demand=13   offer fires, cap does not bind (D searched, not copied)
  dina    beat 10  stack=8   announced=3 frozen=5   asks=6  skips=5   sum=11
  dellian beat 14  stack=154 announced=2 frozen=152 asks=4  skips=152 sum=156
  dellian beat 14  mint: NoCertification, spent=26=cap, denied, scans=36, 1.79 s (debug)
  id freshness: dellian 227 announcements / 50 resolutions / 0 revivals; dina 88/17/0

R21(b)'s SUM IDENTITY is CORRECTED, not copied: the plan's "sum = current.stack.len()
= 154-156" is HEAD-era. Post-U3 the domain is `announced ∪ (stack \ frozen)`, so the
measured sum is `announced + stack` (156 and 11). The plan's asks/skips figures
themselves reproduce.

R21(b-placement-B)'s stated matched pair is FALSIFIED and re-keyed: the unmutated
dellian beat-14 board does NOT offer (item (4) already trips at stack index 35, and
that entry is ITSELF frozen), so no mutation is needed — item (4) scans 36 entries
where only 2 are non-exempt, while conjunct (6) on the same touch skips all 152.

R27(d) is NOT WRITTEN, measured vacuous four ways, including running the plan's own
stated revert (comparand ring <- .live): 4/4 green, nothing flips. Basis A certifies
0 times corpus-wide; `frames_per_period`/`delta` come from `ring_delta_signature`,
which reads `f.normalized` directly from the sample and never consults the mint's
`ring` vec; `residual_board_delta` is byte-identical across the halves.

R16(ii-a)'s `spent <= PROBE_BUDGET` is VACUOUS by construction (a budget cannot
overspend), so the row ships the denial flag plus an exact-demand sweep across the
seam's closed cap domain: `Lowered(13)` offers, every `Lowered(n < 13)` refuses.
R16(iv)'s ceiling is DEBUG-scaled and says so.

Eight revert-probes applied, seven flipped, all restored byte-identically.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): retained-sample .live-reader rows close U3 (5d, R27 a3/b/c/e)

Four arms on a CONSTRUCTED board driven through the real mint, where ring
frame 2 carries an entry neither frame 1 nor `current` holds — so the
announced pair genuinely arrives from a retained sample and the shared
carrier revert (ring_live <= .normalized) has a BEHAVIOURAL flipping site,
defeating the round-7 blind-spot tripwire for (b)/(c):

- (a3) retained sample's Effect::Token derivation equals the live board's;
  control shows the .normalized half derives a different set. Flips on P-A
  (clear_trigger_identity_recursive source_id scrub deleted): (940,940) vs
  (940,0). No mint-level flip — measured and disclosed in-code (both
  derivations are event_is_accounted).
- (b) stored MayChoice auto-record survives the ring: no record => OFFER
  publishing exactly 1 MayChoice point; record seeded => UnspecifiedChoiceWindow.
  Flips on P-B (carrier revert): the negative OFFERS.
- (c) intervening-if binds with the retained sample's trigger source; the
  "classifies identically to current" conjunct and a MayPrompt control on the
  normalized half ride the same row. Flips on P-B: the positive REFUSES.
- (e) discharge reads the pair's carrying frame, not the live board — four
  boards (frame-only / both / neither / causeless) plus the frame_ix
  pointer-identity structural conjunct. Flips on P-C
  (resolution_events_are_discharged frame => state), both-boards arm green in
  the same run.

R8 census tripwire fired benignly: (22,12) => (22,13) — production half
unchanged, per-file multiset unchanged; the +1 is (b)'s cfg(test) read of the
offer schema, adjudicated per the row's protocol in its doc and failure message.

All probes restored byte-identically (sha-verified). CR gate: 12 numbers, 0
unverified.

Assisted-by: ClaudeCode:claude-opus-5

* feat(engine): OptionalEffectChoice pin-injection arm with seat and beat guards (5d U4)

inject_pinned_answer gains the CR 603.5 MayChoice consumption arm (plan section 3
D5 verbatim) with both total head guards in source order — seat
(*player != template.owner => RecastAbort, CR 603.5) and beat
(work.pending_trigger.is_some() => RecastAbort, CR 603.3c announcement-time
question is never answerable by a resolution-time pin) — plus the two injector
doc corrections the arm falsifies (Targets-pin/MayChoice-pin split; the _ arm's
remainder is "(mode / unless / X)").

Rows: R23(4) seat matched pair; R23(5) beat matched pair on the SAME source_id
(differing-source would be refused by the slot lookup and report the wrong
guard); R28(b) re-keyed on measurement — the plan's "drive seam must still
RecastAbort for a matching hostile owner" is FALSE and its own cell says why
(the comparand is client input): (b1) asserts the breach loudly with the re-key
instruction in-code, (b2) asserts the declare-seam refusal that makes the
ingress unreachable, matched positive included. Fourth row added for the accept
mapping (both MayChoiceOption directions — live inverted-mapping failure mode
with no plan row). R23's (5-reach) ships with R2 in U5.

Four revert-probes run, flipped, restored sha-identical. P3 (declare firewall
deleted) flips (b2) printing the tampered proposal reaching APNAP; (b1) is a
measured must-not-flip control; U2's r28_a/r28_a'' flip on it too, r28_c stays
green. Duplicate-needle tripwire fired once on P4's first application (doc
quoted the production expression verbatim); doc rewritten, needle re-measured 1,
probe re-run and flipped.

Census adjudications, never relaxed: CR 603.5 prompt census 34=>37 (producer
half unchanged at 5); R8 offer-writer test half 13=>14 (production unchanged at
22, multiset byte-identical) — new sites named in doc + failure message.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): track the F4 4p dump and pin the bounded-offer behaviour it actually has

Stages `crates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz` for the
first time, together with the integration module that loads it through the production
chokepoint (`PersistedGameState::into_game_state`) and drives it beat-by-beat through
the public `apply()` — no synthetic `GameScenario` stands in for the real board.

Rows: R18 (name-resolver fails loud both ways), R1 + R1b (offer fires, bound re-derived
independently of `elimination_bounds`, published point set), R2 (what an accepted
declaration commits), R23 5-reach (no answered may-beat carries a construction cursor),
R9 (refusal keyed to a derived replacement obligation, not a definition name), R16
(exact probe demand at the offering beat), R27 a1 (the recorded sample keeps a live half
normalisation would erase), and R7 in `analysis/resource.rs` (the frozen prefix is an
index-id identity, never a presence count).

MEASURED CORRECTIONS, pinned rather than asserted-as-planned:

* The bounded offer FIRES on the real dump (beat 43, P0, max_iterations = 35, basis B)
  but publishes ONE decision point, not the three §6 R1 predicted, and an accepted
  declaration therefore commits ZERO cycles at both n = 1 and n = 3 — it answers Sue's
  `may` from the pin and then aborts on Reed's unpinned `may`. Fail-CLOSED (rollback,
  CR 800.4a handback): nothing rules-wrong ships, but it is not a grant. R1/R1b are
  labelled PARTIAL and §6 R2a/R2b/R3/R4/R5 plus the interruptibility matched pair are
  deliberately NOT written — no row asserts a falsified prediction.

* CONSEQUENCE-DISCLOSURE of the ratified `PROBE_BUDGET = 26`: §6 D1/D2 (dellian) are
  unreachable and are NOT written. dellian drives 309 beats through `apply()` to
  `GameOver` with no offer ever raised; every mintable beat refuses
  `UnspecifiedChoiceWindow` with `spent = 26 = PROBE_BUDGET, denied = true`. dellian's
  measured demand at that seam was 96–107, so this is budget exhaustion by design of
  the cap, not a detector fault.

Remedy sizing for the follow-up is journaled with measurements (widening `announced`
is insufficient alone; the sampler gate is not the seam — two relaxations left the frame
census unchanged; the resolution-order sequence is empty at the offering beat).

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): pin what the AI can actually do at the real F4 bounded offer

§5 U6's AI-verification step, keyed to the measured reality rather than to the
plan's three-slot prediction. Drives the tracked 4p F4 dump to its real CR 732.2a
offer through `apply()` and measures `engine::ai_support::legal_actions` there —
the seam `phase-ai/src/search.rs` reaches via
`WaitingFor::LoopShortcut { .. } => engine::ai_support::legal_actions(state)`.

MEASURED: the generator emits exactly two candidates,
`DeclareShortcut { count: UntilLethal, template: None }` and `DeclineShortcut`.
Its `Fixed(max_iterations)` candidate — which exists precisely for bounded offers —
is gated on `schema.points.is_empty()`, and this offer publishes one point, so it is
excluded; the surviving declare candidate is refused outright by
`handle_declare_shortcut` (`UntilLethal` against a narrowed bound). The AI's only
effective action is therefore DECLINE, which the phase-ai policy independently
reaches: the offer latches `predicted_winner: None`, routing `LoopShortcutPolicy`
to its `(None, UntilLethal) => reject` arm. These rows supply the reachability that
policy's own row cannot — a real captured board carrying exactly that pair.

The whole declare option space is measured on one board: `UntilLethal` + None,
`UntilLethal` + a conformant template, and `Fixed(max)` + None are all refused into
the CR 800.4a handback; `Fixed(n)` + a template pinning every published point with
`owner == proposer` is ACCEPTED and opens the CR 732.2b window — the anti-vacuity
control, and the shape the generator never emits. A third row exercises the
declare-time `template.owner` firewall on the real dump (the staged-offer arm lives
in `loop_shortcut.rs`'s `r28_a`).

THE GAP IS REPORTED, NOT CLOSED, for two measured reasons: an accepted declaration
on this board commits ZERO cycles (the unannounced-`may` defect pinned by `r2`), so
a candidate-generator fix rides the grant mechanism and inherits its hold; and
building the template needs a new engine authority for the pin CONTENT, which is a
design decision rather than executor-local plumbing.

§6 R8's U6 invariance arm — the one arm §5 leaves open — PASSES: U6 touches no tree
R8 walks (`git diff --stat HEAD -- crates/engine/src crates/phase-ai/src` is empty),
the raw `WaitingFor::LoopShortcut {` census is unchanged at 37, and R8's own tests
stay green at `(production, test) == (22, 14)` with the `ai_support/candidates.rs 1`
multiset entry intact. No census update is owed.

Assisted-by: ClaudeCode:claude-opus-5

* docs(engine): review-impl LOW fixes — board-not-prompt contract, real Braids text, census pin

Three LOW findings from the independent full-diff review, applied without any
logic change:

- The three-field "pure over (stack, objects, proposer)" wording at the mint's
  three sites is replaced with the board-not-prompt contract the U2 doc already
  states (the mint reaches eleven GameState fields through
  optional_prompt_player; what callers rely on is PROMPT-independence). The
  CR 603.3c relief justification is restated on the stronger premise: the
  pending_trigger_entry cursor is prompt-coupled by every production writer.
- The Braids, Conjurer Adept quotes at three sites now carry the card's real
  Oracle text (verified against Scryfall twice, executor and reviewer
  independently) with the fixture's Effect::PutCounter named as a synthetic
  stand-in for the class, sound because the branch is effect-agnostic.
- .combofb-5d-cr-gate.md gains a committed-range reconciliation: 49 distinct
  CR numbers on added lines, 0 unresolved, +7 previously untabled, 2 stale
  rows superseded; two independent instruments agree and a fake-number
  positive control proves discrimination.

One knock-on adjudicated per the census test's own protocol: the +7 net doc
lines above engine.rs:10493 moved the CR 603.5 producer to :10500; the pinned
string is re-adjudicated with the cause named (producer byte-identical; the
total-37 and 5/7/25 partition asserts fired green in the same red run, proving
the set never moved). Near-miss recorded in the module doc: an adjudication
doc must never quote the census needle verbatim — it would self-count.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): derive CR 603.7 firing carriers for the 4p dump corpus

Upstream #6842 (8121fd1c6) made a `TriggerFiring` carrier MANDATORY on every
persisted triggered record and fails CLOSED without one. The six 4p dump
fixtures this branch drives were captured before that commit, so on the rebased
base they no longer decode at all: 41 of the 44 red rows were production-decoder
rejections naming the three carriers (`active legacy pending trigger has no
firing discriminator`, `triggered stack entry has no firing carrier`,
`resolving triggered entry has no firing carrier`).

The carrier cannot be RECOVERED, only DERIVED. The read-only pristine root
(`combofb-dumps-pristine/`, captured 2026-07-22/25) predates #6842 too and
records zero firing carriers, so re-deriving from pristine cannot mint a value
that was never captured.

`TriggerFiring::UnknownLegacy` is not an escape hatch, and this was measured
rather than assumed: `validate_firing` (7 call sites, types/game_state.rs
:7688/:7691/:7700/:7721/:7732/:7749/:7779) returns
`Err("... has no canonical trigger firing discriminator")` for it. It is the
field-absent marker (`skip_serializing_if`) and the redaction default, never a
legal persisted value.

THE DISCRIMINANT (CR 603.1 ordinary vs CR 603.7a delayed), applied per record:
  Ordinary <= the fired trigger's definition is present on its SOURCE OBJECT's
              own `trigger_definitions`/`base_trigger_definitions`, matched by
              exact `description`. A printed or granted triggered ability of a
              permanent is an ordinary triggered ability.
  Delayed  <= the trigger has an install receipt in `delayed_triggers`.
Anything else ABORTS by name. There is deliberately no fallback stamp: a wrong
carrier silently re-classifies a CR 603.7 firing identity, which is precisely
the inference upstream refuses to make.

Measured, per fixture — carriers stamped, and `delayed_triggers` length:

| fixture | carriers | delayed_triggers | sha256 (stamped) |
|---|---|---|---|
| dellian_emblem_conqueror_4p | 154 | 0 | 3f8b06eb7986de10 |
| dina_conqueror_4p | 7 | 0 | 773e902daccf7199 |
| fantastic_four_bounded_loop_4p | 3 | 0 | 89ff377b53b7bd55 |
| tenacity_exquisite_blood_4p | 6 | 0 | a296ab6e37129ca3 |
| witherbloom_sprout_lumaret_4p | 1 | 0 | 71efd26867e9fc57 |
| witherbloom_sprout_lumaret_simple_4p | 1 | 0 | 607fbe632e759692 |

172 carriers total, ALL `Ordinary`, ZERO undetermined. Every fixture records
`delayed_triggers: []` and no install journal, so `Delayed(Some(prov))` could
not have validated regardless — `validate_firing` demands a registered install
root. Every one of the 172 was classified from its own record's source object,
never defaulted; the per-record witness table (carrier, source id, card name) is
in the PR body.

Five resolving entries elsewhere in the corpus are NOT migration targets and are
deliberately left unstamped: `combo_infinite_pile_4p_untapped_precast`,
`kilo_freed_relic_pentad_4p`, `sprout_witherbloom_realistic_lands_4p`,
`basalt_power_artifact_infinite_colorless`, `combo_infinite_pile_4p_offer`.
Each `kind.type` is `Spell` or `ActivatedAbility`, not `TriggeredAbility`, and
the validator's `(Some(_), None) => {}` arm accepts a non-triggered resolving
entry with no carrier. An earlier classifier of mine flagged these as
UNDETERMINED; reading each entry's `kind.type` showed the flag was wrong.

SECOND FIELD CLASS — the CR 603.7 delayed-trigger ALLOCATORS. Restoring the
dumps exposed a further #6842 change that the decode failures had been masking:
`next_delayed_trigger_token` carries `#[serde(default)]`, so a pre-#6842 dump
that omits it restores as 0 through a bare `GameState` decode, while the
production `PersistedGameState` path runs a load-time repair
(`next = max(existing // 1, max_used_token + 1)`) and restores 1. The two
decoders therefore disagree on a legacy dump, and 0 is invalid on its face:
`validate_trigger_firing_coherence` rejects
`next_delayed_trigger_token <= max_token`, which is 0 when there are no install
roots.

This surfaced as the R8 state-neutrality arm of
`migrated_dump_decodes_through_both_decoders_and_unmigrated_through_neither`
reporting `differing paths: ["state.next_delayed_trigger_token"]`. That arm is
NOT relaxed. The fixtures are stamped with the repaired value instead, which
makes them look like a modern capture, keeps both decoders in agreement, and
survives the eventual deletion of the load-time shim.

Measured, all six fixtures: field absent, `delayed_triggers: 0`, and ZERO
`DelayedTriggerInstall` commands across 158/30/2/516/50/5094 journal entries. So
both used-token sets are empty and the formula collapses to `max(1, 1) = 1`. The
scan is not vacuous: injecting one synthetic install command into a fixture makes
the same selector report 1 instead of 0.

The GENERAL derivation of the used-token set is deliberately NOT reimplemented
in jq — it walks `resolved_rules_journal` install commands and `delayed_triggers`
provenance with reuse and nonzero checks, and re-deriving it here would repeat
exactly the mistake this script's sibling refuses to make for `EffectKind`. Only
the collapsed no-install-roots case is stamped; anything else ABORTS BY NAME,
verified by probe (one injected install command =>
"UNDETERMINED delayed-trigger allocators: 1 install command(s) ...", nothing
written, exit 1).

MECHANISM. `scripts/lib/trigger-firing.jq` holds the SINGLE definition of the
derivation; both the pristine-regeneration path (`migrate-dump-fixture.sh`) and
the in-place path (`stamp-fixture-firing.sh`) load that one file, so neither can
certify its own copy of the recipe.

WHY IN PLACE RATHER THAN A PRISTINE REGENERATION for these six. Regeneration is
the stronger provenance and is preferred where it applies, but it does not apply
here: measured, `dina_conqueror_4p` and `witherbloom_sprout_lumaret_simple_4p`
differ from their pristine regeneration in exactly one object each (Priest of
Forgotten Gods' `abilities`/`base_abilities` AST), because the committed fixture
carries a LATER parser state than the capture. Regenerating them would silently
REVERT that. Stamping in place is additive and cannot revert anything.

ALL THREE control arms are enforced, and the stamper refuses to write if any
fails:
  arm 1 NO_COLLATERAL   the stamped artifact minus the five stamped keys is
                        BYTE-IDENTICAL to what was committed, so nothing but the
                        carriers moved.
  arm 2 CARRIERS_ADDED  got == need && got > 0, keyed on CARRIER COUNT rather
                        than on byte difference. An earlier revision of this arm
                        keyed on bytes and reported a false pass for zero-carrier
                        fixtures, where gzip/jq re-serialization alone changes
                        bytes without stamping anything. Fixtures needing zero
                        carriers are now SKIPped outright.
  arm 3 ALLOCATORS_CANONICAL
                        both allocators exist and are >= 1 — i.e. above the
                        value the engine's own coherence validator rejects.

Also fixes a pre-existing defect in `migrate-dump-fixture.sh` that the corpus
sweep exposed: the `target_slots` stage used an unguarded `|=`, which aborts
with "Cannot iterate over null" on a dump paused at a beat with no target
prompt. The script was therefore only ever usable on 2 of the 6 dumps. The stage
is now guarded, so ONE recipe covers the whole corpus. The `gameState`-envelope
guard in the jq library is the same class of fix, caught by the control before
any write: without it, `.gameState |= ...` would have CREATED a `gameState` key
on the four `turn_number`-enveloped dumps, i.e. corrupted them.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): re-anchor the CR 603.5 prompt census after the rebase onto #6842

The census tripwire pins the exact source coordinates of every CR 603.5 prompt
producer so that a SIXTH producer is a counted event rather than a silent one.
Rebasing this branch onto upstream #6842 (8121fd1c6) moved five of those
coordinates, and the row fired.

ADJUDICATED, NOT RELAXED. The row fired on COORDINATES, not on population. The
producer count is still 5, not 6:

  game/effects/mod.rs                 :5896 :5973 :8927  =>  :5918 :5995 :8949
  game/engine.rs                      :10500                =>  :10589
  game/effects/scoped_library_search.rs :452                =>  :452  (UNMOVED)

`mod.rs` is a uniform +22 shift and `engine.rs` a +89 shift — the signature of
lines inserted ABOVE each site, not of a new producer. That
`scoped_library_search.rs:452` did NOT move is itself evidence the SET did not
change: a genuinely new producer would not leave an untouched file's coordinate
fixed while shifting the others by a constant.

The producer TEXT at all five sites was verified byte-identical between the
pre-rebase tree (`chain3-prefold-backup`, dbc81821d) at the old coordinates and
this tree at the new ones. So the expectation array is re-anchored to the new
coordinates and the assertion keeps its full strength: a sixth producer still
fails this row.

Correcting my own earlier report for the record: I previously stated that
upstream had ADDED a CR 603.5 producer. That was wrong — the count never left
5, and the failure was purely positional. The correction is carried in the
tripwire's own doc block so the next reader of this row does not inherit the
mistake.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): re-anchor the CR 603.5 prompt census after the rebase onto #6851

CI built the MERGE ref (branch + main 96e41b3ab) while the branch was still
based on e12447f4f, so the census row fired in CI but not locally. Re-anchored
per the row's own protocol — re-derive the SET first, prove byte-identity, then
move coordinates — never relaxed.

MEASURED, three independent ways:
1. Producer SET is still 5 and the partition is still 5/7/25 (total 37). Only
   ONE coordinate moved: game/engine.rs:10589 => :10640. The other four
   (game/effects/mod.rs:5918/5995/8949, scoped_library_search.rs:452) are
   UNMOVED.
2. All five producers re-read at their new coordinates and diffed against the
   pre-rebase tree at their old ones: BYTE-IDENTICAL. Negative control confirms
   the diff instrument discriminates — the new tree at the OLD coordinate
   :10589 is a bare `}`, not the producer.
3. The +51 is fully accounted for by #6851's own insertions above this
   producer: `git diff -U0 e12447f4f 96e41b3ab` nets +51 above line 10589 and
   +51 across the whole file, so predicted 10589+51 = 10640 equals the observed
   coordinate exactly and #6851 adds nothing below it.

A sixth producer remains a counted event.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): close two fail-open seams in the loop-shortcut analysis proofs

Addresses maintainer Critical 1 and Medium 6 on #6886, plus CodeRabbit
3699361027 / 3699361052 / 3699361032, all in `analysis/resource.rs`.

1. CRITICAL — `ShortcutProposal.per_cycle` could not survive the PRODUCTION
   persistence path. Adds a serde adaptor riding the four `PlayerId`-keyed
   `ResourceVector` maps as pair SEQUENCES, generalizing the existing
   `counter_key_pairs` into `map_key_pairs` so one definition covers the tuple
   key and the player keys.

   MEASURED MECHANISM (the old doc comment asserted the opposite, and was
   wrong): a bare `BTreeMap<PlayerId, i64>` is fine through `from_str` and
   through `from_value` IN ISOLATION — which is why the existing
   `periodic_delta_survives_the_serde_json_wire` arm passed and gave false
   confidence. It breaks only under the ENCLOSING shape: `WaitingFor` is
   `#[serde(tag, content)]`, so its payload is buffered through serde's
   `Content`, which stringifies map keys, and `PlayerId` is
   `#[serde(transparent)]` over `u8`. `PersistedGameState::deserialize` routes
   EVERY decode through `serde_json::Value` + `from_value`, including the WASM
   restore at `engine-wasm/src/lib.rs`'s `from_str::<PersistedGameState>` — so
   `from_str` at the boundary does not save it. Measured on serde_json 1.0.149;
   the failure text is `invalid type: string "0", expected u8`, exactly what
   `tests/integration/loop_shortcut.rs` had already recorded as a standing
   limitation. `generic_triggers` keeps its bare map: `TriggerKind` is a
   unit-variant enum, measured Ok through the same path.

   NEW ROW `a_populated_per_cycle_proposal_survives_the_production_persistence_
   boundary` drives `from_value`, the `PersistedGameState` boundary, and the
   WASM bridge's own `from_str::<PersistedGameState>`, with all four
   `PlayerId`-keyed maps populated behind a reach-guard.
   REVERT-PROBE, run: dropping `#[serde(with = "map_key_pairs")]` from `life`
   ⇒ FAILS with that exact error text; restored byte-identical.

2. MEDIUM — an EMPTY `FreeUnlessReplacements` derivation discharged the
   CR 616.1 obligation vacuously. `!events.iter().any(..)` is `true` for an
   empty slice, and the only thing preventing it was a `debug_assert!`, which
   compiles out of release — so the fail-open case was live in the build that
   ships. Now a first-class refusal in every build. A refusal rather than a
   panic on purpose: it matches every other seam in the module, and a
   `debug_assert!` could not be covered at all, since it aborts the build tests
   run in.
   REVERT-PROBE, run: deleting the arm ⇒ the empty case flips to `true` and the
   new row FAILS while the non-empty arms stay green; restored byte-identical.

3. Comment defect (CodeRabbit 3699361032): the zero-delta early return claimed
   "every longer period is a whole number of copies of this one". The
   repetition test inspects only the most recent `2k` deltas, so a larger `k'`
   need not be a multiple of `k`. Behaviour is unchanged and still fail-closed;
   the false justification is corrected in place, with the counter-example,
   because it is the kind of claim a later reader would lean on to widen the
   search while keeping the early return.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): refuse the resolution probe when a prompt already stands

Maintainer Critical 2 on #6886, and CodeRabbit 3699361064.

`probe_resolution` compared only the `WaitingFor` DISCRIMINANT of the probed
clone against the incoming board. When the incoming board already carries a
non-priority variant, a resolution that re-parks the SAME variant leaves the
two discriminants equal, so the probe reported the resolution CHOICE-FREE while
an unanswered choice sat on the board. That is fail-open in the one direction
this function exists to close, and no comparison against the incoming variant
can see it — the incoming variant is exactly what masks it.

Now keyed on "is there a prompt at all": a non-`Priority` `waiting_for` on the
probed board is itself a refusal. The incoming board is a RESOLUTION BOARD by
this function's own documented contract, so a standing prompt is a reason to
refuse rather than a baseline to compare against. The discriminant test is kept
alongside it, so the guard is STRICTLY STRONGER on every input than the struck
form: it can only ever cost coverage (a missed offer), never soundness — the
same direction as the budget-exceeded and empty-derivation arms beside it.

NEW ROW `a_prompt_standing_on_the_incoming_board_refuses_the_probe`, a MATCHED
PAIR over all six allow-listed arms: each arm must still reach `Events` from a
priority board (positive control — without it a probe that refused everything
would pass), and must return `Prompted` when the same board carries a standing
`ReplacementChoice`. The row asserts the resolution does NOT clear that prompt,
so the discriminants really are equal and the struck guard could not have
caught it.

REVERT-PROBE, run: restoring the bare discriminant comparison ⇒ the negative
arm FLIPS TO FAIL while the positive control stays green; restored
byte-identical.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine,ai): offer only legal quantity choices on a bounded shortcut

Maintainer Medium 5 on #6886, and CodeRabbit 3699361023 / 3699361081.

The candidate generator emitted `IterationCount::UntilLethal` unconditionally
for every `WaitingFor::LoopShortcut` node, including bounded offers.
`handle_declare_shortcut` rejects that combination outright
(`IterationCount::UntilLethal if offer.schema.is_bounded()` =>
`reject_shortcut_declaration`, `crates/engine/src/game/engine.rs`), and that
reject is a SUCCESSFUL fail-closed handback — `Ok(result)`, not an `Err`. So
the candidate was not merely a wasted search node: the simulation layer was
handed an action the engine accepts and then silently discards, i.e. an illegal
quantity choice wearing the shape of a legal one, which the policy layer then
had to know to score away.

`UntilLethal` is now gated on `!schema.is_bounded()`. A bounded offer still
gets `Fixed(max_iterations)` where its pin set permits a `template: None`
declaration; where neither applies, `DeclineShortcut` genuinely is the only
legal answer at the node, and representing that honestly is the point.

Paired AI-side guard: `LoopShortcutPolicy`'s final bounded arm matched every
remaining `Fixed(n)` INCLUDING `n == 0`, so a zero-count declaration — legal
and representable, per
`a_zero_count_declaration_validates_over_an_empty_range_but_still_checks_
cardinality` — would have been scored into the CRITICAL band. It commits no
cycles while spending the CR 732.2b response window, the same weak-domination
shape the over-bound and `(None, UntilLethal)` arms already reject. Unreachable
from today's generator, so no current ranking moves; the arm now states its own
precondition instead of relying on an invariant maintained a crate away. Uses
`PolicyVerdict::reject`, not a raw sentinel.

R8 OFFER-WRITER CENSUS: unaffected, and checked rather than assumed. The census
counts occurrences of the `WaitingFor::LoopShortcut {` token; this change edits
the BODY of an existing match arm and adds or removes no such token, so the
pinned (22, 14) pair and the per-file production multiset are untouched.

Assisted-by: ClaudeCode:claude-opus-5

* fix(scripts): read both trigger-definition shapes and stop overwriting carriers

Maintainer Medium 3 on #6886, and CodeRabbit 3699361085 / 3699361087 plus the
non-object `command` nitpick. Also folds in the promised doc correction.

1. `_defs` read `.description` across BOTH definition lists, but they do not
   serialize alike. `trigger_definitions` is `Definitions<TriggerEntry>` and
   `TriggerEntry` is `{occurrence, definition}`, so its text is at
   `.definition.description`; only `base_trigger_definitions`
   (`Vec<TriggerDefinition>`) exposes `.description` directly.

   MEASURED on the committed corpus, which is what the maintainer asked for and
   what the earlier "all 172 carriers matched non-null" claim did not
   establish: of the `trigger_definitions` entries, ZERO expose a direct
   `.description` and 100% nest it (145 / 165 / 132 on dellian / dina /
   witherbloom). The live list therefore contributed NOTHING — every entry
   collapsed to the `// ""` fallback — and all 172 carriers resolved through
   `base_trigger_definitions` alone. Total descriptions visible to the
   derivation across the corpus: 875 before, 1755 after.

   REACHABLE, not theoretical: `dellian_emblem_conqueror_4p` carries a GRANTED
   trigger ("When ~ dies, you gain 1 life.") present in the live list and
   absent from the base list. A firing whose description existed only there
   would have aborted the whole stamp on a classifiable fixture.

   BEHAVIOUR-PRESERVING on the corpus: 172 carriers resolve before AND after,
   all `Ordinary`; the pristine regeneration stays BYTE_IDENTICAL=true.

2. `stamp_trigger_firing` assigned all three carrier keys unconditionally
   without reading them, so `stamp-fixture-firing.sh`'s header claim that
   in-place stamping "cannot revert anything" was false for exactly those keys
   — and arm 1 structurally cannot catch it, because it deletes them from both
   sides before comparing. An already-canonical `Delayed` carrier would either
   abort the stamp or be silently rewritten to `Ordinary`, the CR 603.7a to
   CR 603.1 re-classification this library exists to refuse. Now derives only
   into an ABSENT slot, which also makes the stamp idempotent. Preservation is
   scoped to stack entries that are still on the stack, so a stale key cannot
   accumulate and inflate the carrier total past the number of triggered
   records — the one shape that could have let arm 2's aggregate comparison
   cancel a surplus against a deficit.

3. `select(.command.DelayedTriggerInstall)` indexes `.command` with a key,
   which aborts jq with a raw type error on a serde unit variant (a bare JSON
   string). This file's contract is that undetermined cases abort BY NAME;
   filtered to objects first so the probe stays total.

TWO NEW PRE-FLIGHT CONTROL ARMS, both with negative controls, because no
fixture-level arm can witness either property:
  arm 4 DEFINITION_SHAPES  — `_defs` resolves a nested-only description AND a
        direct-only one, and still ABORTS on one present in neither.
  arm 5 CARRIER_PRESERVED  — an existing canonical carrier survives, while an
        absent one is still derived.
REVERT-PROBE, run: restoring the old `_defs` ⇒ arm 4 reports `nested=FAILED`
and t…
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