ship/p03s2 draw delivery - #5686
Conversation
… 03 / 2b) Replaces the single `GameState::pending_multi_draw` slot with `draw_sequences: DrawSequenceStack` — an ID-addressed stack of in-flight draw instructions — and collapses the draw driver's callback chain into one post-pause authority, `effects::draw::resume_draw_sequence`. Why a stack and not a slot. CR 121.2 makes a `Draw N` an instruction that performs N individual draws; CR 616.1g allows a replacement applied to one of those individual draws to itself contain another event. The old `Option<...>` slot could not represent that nesting: a substituted inner draw overwrote the outer instruction's state and the outer's remaining units were silently lost. Frames are addressed by `DrawSequenceFrameId`, never by position, so a resume proves it is driving the instruction it parked rather than whatever happens to be on top now. Behaviour-preserving. No parser code is touched, and the full-pool card-data export is byte-identical (sha 95a979814e2e4e1f07ec0f2eb0fe435bf3ef8cdd457ac6e1 e292cdab6cd45d16, unchanged). The 3 pre-rewrite pins in `draw_from_general_post_replacement.rs` and all 4 BUG-PIN assertions (#5652, #5658) stay green, as do 16219 engine lib tests. The draw-replacement producer census (7 rows) and zone-authority Gate B (82 hits / 62 rows) are untouched. Two correctness details that are easy to get wrong here: * CR 104.4b loop equality. `GameState`'s hand-curated `PartialEq` is the loop-detection comparator, and it already excludes identity-bearing state (`transient_continuous_effects`, `resolution_source_relatch`). Comparing the new stack structurally would fold its monotonic `next_frame_id` allocator into loop equality, so two identical board positions that had merely allocated different numbers of draw frames earlier in the game would never compare equal and loop detection would silently stop firing. `DrawSequenceStack::loop_equal` therefore compares position (who draws, units owed, units delivered) and not history — exactly what the predecessor `Option<PendingMultiDraw>` compared. * CR 608.2c, not CR 609.3. The running per-instruction total that a chained "discard that many" reads was annotated CR 609.3 ("if an effect attempts to do something impossible, it does only as much as possible"), which is a different rule. The correct citation is CR 608.2c ("later text on the card may modify the meaning of earlier text"). CR 609.3 remains correctly cited for the partial-draw case in the same file, so this is a precision fix, not the broader 609.3 sweep. Legacy saves keep working: `pending_multi_draw` still deserializes into a read-only legacy field and is migrated to a one-frame stack by `migrate_pending_multi_draw`, called from `finalize_rules_state` alongside the existing `migrate_post_replacement_continuation`.
…g total The two multi-draw test assertions cited CR 609.3 for the per-instruction running total that a chained "discard that many" reads. Grep-verified against docs/MagicCompRules.txt, CR 609.3 is "If an effect attempts to do something impossible, it does only as much as possible" — a different rule. The governing rule is CR 608.2c: "later text on the card may modify the meaning of earlier text." Part of the same miscitation cluster as task #32, but scoped here to the two assertions inside the multi-draw tests this branch rewrote. Deliberately NOT a blanket sweep: CR 609.3 is *correctly* cited elsewhere in the draw path for the partial-draw case (a library with fewer cards than requested really is "do only as much as possible"), so #32 must not be a find/replace.
…ack (Plan 03 / 2c-1) Replaces the five ownerless parallel `GameState` fields (`post_replacement_continuation` / `_source` / `_applied` / `_event_source` / `_event_target`) with one owned record, `PostReplacementDrain`, held on a `PostReplacementDrainStack`. Why. Those five values describe one thing, but they were written by three different install paths, none of which set all five, and every teardown had to remember to null each one by hand. That design has already caused a real bug: elimination.rs still carries the comment "this field was added after the teardown block below was written and was missed until this regression." "A continuation is pending" is now one fact rather than an invariant maintained across ~40 sites. The three install paths disagree about what to do when a continuation is already resident, and that disagreement is load-bearing, so it is now named rather than emergent from where the assignment happens to sit: * `stash_post_replacement_continuation` -> KeepResident (discards the incoming) * optional accept/decline -> Replace (overwrites the resident) * combat prevention riders -> Replace (overwrites the resident) Both policies are lossy, in opposite directions; CR 616.1g explicitly contemplates a replacement applying to an event contained within another, so both can discard real work. `ResidentDrainPolicy` preserves each path's behaviour exactly and makes the divergence reviewable. Turning the stack loose to actually nest is a separate, characterized commit — it changes behaviour. The subtle part: a drain and its continuation do not die together. `apply_pending_post_replacement_effect` used to take the continuation out of the slot EARLY but clear the event-context fields LATE, and that interleaving is required. The continuation must already be gone so a nested "is a continuation pending?" check taken during the dispatch does not re-drain it; but the event context must still be readable, because that is how `TargetFilter::PostReplacementSourceController` resolves "the source's controller draws cards" (CR 615.5) for Swans of Bryn Argoll. No type enforced that ordering — every caller simply had to respect it. `DrainStatus` makes it a state transition instead: `Ready(work)` -> `Dispatching` -> popped. `begin_dispatch` takes the work and marks the drain `Dispatching` WITHOUT removing it; `has_post_replacement_drain()` reports only `Ready` drains, matching what the old `.is_some()` check meant; `finish_dispatch` retires it, taking its event context with it. This is not theoretical. Sabotaging `begin_dispatch` to pop the drain instead of marking it `Dispatching` — the naive refactor — makes Swans of Bryn Argoll go red (verified: the pin at draw_from_general_post_replacement.rs:184 fails, P1 draws 0 instead of 3). The pins written before this rewrite are what caught it. Behaviour-preserving. Full-pool card-data export byte-identical (95a979814e2e4e1f07ec0f2eb0fe435bf3ef8cdd457ac6e1e292cdab6cd45d16, unchanged); 16219 engine lib tests and 2731 integration tests green, including the 3 pins and all 4 BUG-PIN assertions (#5652, #5658); producer census (7 rows) and zone-authority Gate B (82 hits / 62 rows) untouched. Legacy saves keep working: the five flat fields still deserialize into read-only legacy slots and are folded into a drain by `migrate_post_replacement_continuation` (which already handled the older pre-2026-05-09 split-slot shape).
…t (Plan 03 / 2a)
CR 121.2 makes "draw N cards" two distinct things: the instruction, and the N
individual card draws it performs. CR 121.2a says an instruction "can be modified
by replacement effects that refer to the number of cards drawn. This modification
occurs before considering any of the individual card draws. See rule 616.1g." A
replacement definition watches exactly one of those. Until now the engine could
not tell them apart.
`DrawReplacementScope::{InstructionCount, IndividualDraw}` is a restriction on the
DEFINITION — a sibling of `combat_scope`, `destination_zone` and
`damage_target_filter`, evaluated in the same matcher — not a property of the
event. It is `Some` exactly when `event` is `Draw` and `None` otherwise
(`validate_draw_scope`), declared at construction and never inferred later:
inferring it from the execute shape at match time is precisely the CR 121.2a
conflation this axis exists to prevent.
The scope is INERT in this commit. Nothing consults it yet — matching is
unchanged, and the three-stage machine that will gate on it is a later, explicitly
behaviour-changing commit. This is deliberate, not an oversight: the field is
carried, validated and serialized first so that the producers can be audited
before any behaviour depends on them.
All 6 engine producers assign it explicitly:
* parser: the antecedent's grammatical number IS the scope, so the existing
`alt()` now CAPTURES which branch matched instead of discarding it with
`value((), ...)` — "would draw a card" -> IndividualDraw, "would draw one or
more cards" -> InstructionCount.
* parse_conditional_draw_replacement -> InstructionCount (Quantum Riddler).
* Dredge (CR 702.52a) and the runtime shields -> IndividualDraw.
* the Forge `R:` loader -> IndividualDraw, stated explicitly rather than left to
default so `validate_draw_scope` cannot pass on an unset scope.
The gate is a cross-check, not a presence check. `draw_replacement_census.py` now
requires the producer-declared `draw_scope` to EQUAL the scope it derives
independently from the definition's shape. Checking only that the field is present
would be vacuous — a producer that defaulted every Draw to IndividualDraw would
sail through. Verified non-vacuous: giving Dredge the wrong scope makes the gate
fail on Dakmor Salvage with the exact mismatch.
mtgish-import waiver (granted): adding a field to `ReplacementDefinition` is E0063
at that crate's 9 struct literals, and there is no zero-touch option. The edit is
strictly mechanical — 8x inert `draw_scope: None`, 1 real scope, 1 import line, no
logic, no cleanup, no formatting. NOT fixed with a `Default` derive: the E0063 is
the compile-time audit working, and a Default derive would silently defuse it at
~91 literals for every future field.
One premise correction: the mtgish Draw scope is NOT a constant. Forge's
`ReplacableEventWouldDraw` carries both a singular antecedent
(`APlayerWouldDrawACard`) and count-form ones (`APlayerWouldDrawOneOrMoreCards`,
`APlayerWouldDrawTwoOrMoreCards`), so the scope follows the event variant — the
same rule the Oracle parser applies.
Export delta, stated as separate ledgers: 35396 cards before and after, 0 added, 0
removed; exactly 51 changed, and all 51 are explained ENTIRELY by the added
`draw_scope` key; 0 changed for any other reason. The intended schema bump, scoped
to the Draw corpus, with no collateral parser drift.
16219 engine lib tests, 2731 integration tests, 3 pins and 4 BUG-PINs green. The
producer census (7 rows) is byte-identical and needs no refresh: its population is
"sites that mint a Draw definition", and this commit added scope INSIDE the
existing 7 rather than adding or removing any.
…ent is right by accident Two annotations on the post-replacement drain, so the shipped tree tells the truth about what it does and does not know. **`PostReplacementDrain::applied` is co-owned, not merely co-located.** It lives inside the drain on the strength of a census of every use of the field before the bundling (`git grep post_replacement_applied d1f7d05`, 7 sites): exactly ONE read — `apply_pending_post_replacement_effect`'s `std::mem::take`, which IS the drain — and every write and clear sits with a continuation install. `combat_damage.rs`'s clear is the line immediately above the continuation it is zeroing the set FOR. So the set never lives without a drain and is never read except at drain time. The reading that it has an independent lifecycle comes from looking at the *instant* of that clear rather than its *purpose*. **`ResidentDrainPolicy::KeepResident` is currently correct BY ACCIDENT**, and the obvious fix is a two-card regression. Discarding the incoming continuation looks like a plain bug; letting the drain stack nest looks like the fix. It is not. Instrumenting every collision across the full suite finds exactly two live occupants, and in both the discarded continuation is BYTE-IDENTICAL to the resident one: * Wolverine, Fierce Fighter — RemoveAllDamage{SelfRef}, stashed once per damage instance in a combat batch; * Krark's Thumb — FlipCoins{Multiply{2, EventContextAmount}}. The discard is therefore *accidentally de-duplicating*: the same replacement's continuation is stashed twice, the second is dropped, and the effect runs once — which is right. Naive nesting runs both: Wolverine heals twice, Krark's Thumb doubles twice. No pin covers it. The slot conflates two rules. CR 614.5 — a replacement "gets only one opportunity to affect an event or any modified events that may replace that event" — says the SAME definition arriving twice must be suppressed. CR 616.1g — "one replacement or prevention effect may apply to an event, and another may apply to an event contained within the first event" — says a DIFFERENT definition on a contained event must nest. A blind occupancy test satisfies the first by accident and destroys the second. The fix gates on the identity of the replacement, not the occupancy of the slot: suppress when the incoming ReplacementId is already in the event's `applied` set, nest otherwise. Those two cards are the regression tests. Tracked in issue #5676; successor brief in .planning/architecture-remediation/P03-2C2-HANDOFF.md. No behaviour change: comments only.
…(Plan 03 review) ## Blocker: nested mandatory post-effects were being stranded `PostReplacementDrainStack::install(KeepResident)` collided on `!drains.is_empty()`. That is the wrong predicate, and it is a regression this unit introduced. A drain stays RESIDENT while it dispatches — its event context must remain readable (CR 615.5) — but its continuation has already been taken, so it is no longer PENDING WORK. The predecessor slot expressed exactly this, for the wrong reason: it moved the continuation out of the slot before dispatching, so the slot read empty and a re-entrant stash landed. CR 616.1g: that re-entrant stash is real work. A running continuation draws; the draw is replaced; the replacement carries a mandatory post-effect (Jace, Wielder of Mysteries' win; Abundance's reveal-until). Colliding on mere residency dropped it, and `draw_through_replacement` — which gates its drain on `has_post_replacement_drain()`, i.e. on Ready drains only — then never ran it. `KeepResident` now collides on `has_ready()`, which is the predicate that method's own doc comment already described. Regression tests (`types::game_state::drain_stack_reentrancy_tests`), written before the fix and confirmed RED against the pre-fix tree: * ..._does_not_drop_a_stash_arriving_while_the_outer_drain_dispatches — the fix * ..._still_drops_a_stash_arriving_while_a_ready_drain_is_pending — the guard rail The guard rail matters: the accidental CR 614.5 dedup that Wolverine, Fierce Fighter and Krark's Thumb depend on is on a READY resident and survives untouched. This restores the old semantics exactly and does not prejudge the identity gate (#5676). LIFO bracketing was checked rather than assumed. `finish_dispatch` pops only a last()-Dispatching drain, so an undispatched nested Ready drain would wedge its parent. Instrumented across the full suite: 0 wedges. (Probe validity: ~151 of 153 installs sat at resident=0, so drains are being popped between installs — the probe was live.) ## CR 614.12a was pasted onto plumbing 614.12a is ETB-entry timing ("a replacement effect that modifies how a permanent enters the battlefield"). It does not govern a drain lifecycle. The 11 tags this unit added are re-cited to the rules that actually govern — CR 614.6 (a replaced event's modified actions), CR 615.5 (a prevention's additional effect, which is why the event context must outlive the continuation), CR 616.1g (nesting) — or dropped where the member is plumbing, which CLAUDE.md forbids annotating. The ~52 pre-existing base tags are a separate sweep. ## Validators that claimed enforcement they did not have `DrawSequenceStack::validate` had zero call sites while its doc said "enforced by". It is now a debug_assert at `push()`, so the claim is true. `ReplacementDefinition::validate_draw_scope` had zero call sites while three comments said "checked by". Wiring it at `replacement_definition_for_id` — the single point where the engine resolves a definition it is about to consult — immediately caught 12 real violations: 11 unscoped test constructors (now fixed; Teferi's Ageless Insight gets IndividualDraw, matching its SINGULAR antecedent and the value the real card carries in the corpus, despite being a "doubler" colloquially), plus a finding that is the reason the assert is NOT wired: the committed fixture `crates/engine/tests/fixtures/integration_cards.json` predates the field and carries 7 Draw replacements with no scope (Abundance, Blood Scrivener, Jace, Laboratory Maniac, Living Conundrum, Quantum Riddler, Teferi's). Enforcing at the consult seam fails all 7 until that fixture is regenerated — a separate deliberate act, since a regen sweeps in whatever else has changed in the pool. The claim is downgraded to "checkable by" and names the real authority: the corpus census, which cross-checks every declared scope against an independently derived one. Production is unaffected — card-data.json is regenerated from the parser by CI and all 6 producers set the scope. `validate_draw_scope` now treats `DrawCards` as a draw event, agreeing with `draw_replacement_census.py`'s `event in ("Draw","DrawCards")` scan instead of contradicting it. ## Nit `begin_dispatch`'s Dispatching arm re-assigned a status `mem::replace` had already written, behind a comment asserting a constraint that does not exist. Both deleted. Export-silent: the full-pool export is byte-identical across this commit (sha 31966d51…, 35396 cards, 0 changed). 16221 lib + 2731 integration green; 3 pins and 4 BUG-PINs green; producers 7, corpus 51 + scope cross-check, Gate B 82/62.
The `drain_stack_reentrancy_tests` added with the fix model the bug at the
`PostReplacementDrainStack` API level. The brief asked for the full path, and it was
right to: an API-level test cannot show that the seam is reachable from real cards.
This drives it through the real pipeline:
combat damage -> Swans of Bryn Argoll prevention
-> apply_pending_post_replacement_effect -> begin_dispatch
(Swans' drain is now resident AND Dispatching)
-> Swans' CR 615.5 rider DRAWS for the damage source's controller (P1)
-> draw_through_replacement -> replace_event
-> Jace, Wielder of Mysteries matches (P1's library is empty)
-> apply_single_replacement stashes its MANDATORY post-effect (WinTheGame)
-> install(KeepResident) <-- the seam
Jace's Draw replacement is `mode: Mandatory`, `execute: WinTheGame` — read from the
card data, not recalled. It is exactly the nested-mandatory-post-effect class the fix
exists for.
Verified RED against the pre-fix guard: reverting `has_ready()` to
`!drains.is_empty()` makes the stash get dropped and P1 never wins —
P1 must win: ... Dropping the re-entrant stash strands that post-effect and P1
never wins. got Priority { player: PlayerId(0) }
and green with the guard restored.
CR 616.1g + CR 615.5.
…n plumbing
CR 614.12a is ETB-entry timing — "a replacement effect that modifies how a permanent
enters the battlefield". It does not govern installing or dispatching a
post-replacement continuation. Same class as the 11 removed from game_state.rs; I
swept that file and failed to re-run the sweep across the rest of my diff.
* replacement.rs `stash_post_replacement_continuation` -> CR 614.6. The continuation
IS the replacement's own actions: "If an event is replaced, it never happens. A
modified event occurs instead, which may in turn trigger…" The collision policy is
a CR 616.1g question and is already named in the body.
* replacement.rs optional accept/decline install -> CR 614.6, same reason.
* engine_replacement.rs resident-top dispatch -> CR 615.5 + CR 616.1g. This one was
net-neutral in the tag COUNT (20 -> 20) only because my rewrite replaced a base
tag with my own — the line is mine and carried the same wrong rule, so leaving it
because a counter happened to net out would have been the wrong call.
Net-new CR 614.12a tags across every file this unit touched: zero. Per-file counts are
now at or below base (replacement.rs 20->20, engine_replacement.rs 20->19,
game_state.rs 12->12). The ~52 pre-existing base tags remain task #41.
All CR numbers grep-verified against docs/MagicCompRules.txt. Comments only; no
behaviour change, export-silent.
There was a problem hiding this comment.
Code Review
This pull request refactors the replacement and draw pipelines to strictly adhere to MTG Comprehensive Rules (CR) 121.2, 121.6b, 615.5, and 616.1g. It replaces the single-slot pending_multi_draw with a robust DrawSequenceStack to correctly support nested draw instructions, and consolidates scattered post-replacement continuation fields into a unified PostReplacementDrainStack to prevent state leaks and manual clearing bugs. Additionally, it introduces explicit draw_scope tracking on ReplacementDefinitions to distinguish between individual card draws and instruction-count modifications. As there are no review comments provided, I have no further feedback to offer on this implementation.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Parse changes introduced by this PR✓ No card-parse changes detected. |
…d renames + drain/draw_sequence adds in the partition guard Rebase onto upstream/main 6f7cee8 crosses phase-rs#5686, which renamed six GameState fields to their legacy_ serde-migration names (post_replacement_{continuation,source,applied,event_source,event_target} -> legacy_*, pending_multi_draw -> legacy_pending_multi_draw) and added post_replacement_drains + draw_sequences. Update the exhaustive _gamestate_partition_is_total destructure (no '..', so a field-set mismatch is a hard E0027/E0559) to the new field names; the two new fields join the destructure so the cover-gate re-audit tripwire stays total. Soundness unchanged: draw_sequences is loop_equal-compared in GameState PartialEq; post_replacement_drains is skip_serializing_if=is_empty (settle-empty, loop-neutral). Assisted-by: ClaudeCode:claude-opus-4.8
…ow + combo-declaration UI (CR 732.2a–c) (#5672) * feat(engine): DecisionTemplate replay + residual board delta (PR-7 phase 1) Phase 1 (foundations) of combo-detector PR-7: pure/offline analysis-layer primitives, zero live engine wiring, no gate yet. B1: DecisionTemplate {owner, decisions, replay} + resolve() replay engine + predictability_gate() (CR 732.2a firewall). DecisionSource reuses YieldTarget (CR 117.3d provenance + CR 400.7 identity). ReplayFailure is selected per pin kind: absent Order source -> MissingSource (CR 400.7); illegal/absent target -> IllegalTarget (CR 608.2b). IterationCount::Fixed only; TargetSchedule = {Constant, RoundRobin, Piecewise}. B4: BoardDelta/ResidualPermanent (CR 110.1) + pure board_delta() producer + structurally-empty LoopCertificate.residual_board_delta. The field is empty by construction for every certificate detect_loop can currently produce (loop_states_equal_modulo_resources requires an identical battlefield); board_delta() is the single population seam that lights up once a future object-growth detection path relaxes that gate -- not a silent stub. Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p engine (18543 passed, 0 failed); 12 discriminating inline unit tests. coverage/semantic-audit skipped -- PR-7 touches zero parser/card-data surface so they carry no signal; coverage runs once at final pre-push as a no-regression check. Deferred: DecisionGroupKey/DecisionKind/key + Ord-on-newtypes -> Phase 2/B2 (first consumer); IndexedClass schedule -> Phase 4/B3 (needs live FilterContext); non-empty residual materialization + board_delta output-order determinism -> object-growth detection phase. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): trigger-order resolver + intra-batch re-prompt fix (PR-7 phase 2) Phase 2 (B2) of combo-detector PR-7: one resolver at the begin_trigger_ordering auto-order gate, two tiers over a shared DecisionTemplate, plus the deferred B1 key apparatus. Ephemeral tier (CORRECTNESS FIX, CR 603.3b): a simultaneous trigger batch is ordered ONCE. Previously, when a targeted trigger paused for target selection, the already-ordered deferred tail lost its 'ordered' flag and drain_deferred_trigger_queue_unchecked re-prompted after every target. Now handle_order_triggers registers an ephemeral ThisObject-keyed coverage-only template; the gate's 3rd arm verifies sub-multiset coverage and keeps the tail's chosen order without re-prompting. Cleared at the batch resolution boundary. Persistent tier (UX): opt-in save/reapply keyed by AllCopies/oracle identity, via GameAction::SetTriggerOrderTemplate{Save,Remove,ClearAll} mirroring the merged session bypass + payload guard + manabrew-compat + per-viewer redaction). Permutes the fresh batch once, then registers an ephemeral marker so all parked-tail re-drains are coverage-only for both tiers. Frontend list deferred to phase 5. B1 key apparatus (deferred from phase 1, first consumer here): DecisionGroupKey/ DecisionKind + DecisionTemplate.key + Ord on ObjectId/CardId + decision_templates Vec on GameState with get/set/remove/clear accessors. Excluded from loop_fingerprint, kept in PartialEq (safe direction), per-viewer redacted in filter_state_for_viewer. Gate OFF byte-identical: empty decision_templates no-ops the 3rd arm; neither tier rides LoopDetectionMode. Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p engine (18597 passed, 0 failed, post-rebase); 8 discriminating tests incl. the headline single-OrderTriggers-prompt on a paused multi-targeted batch. FORGE ordering_parity_sweep (full-DB, corpus regenerated at the rebased tip): B2's delta vs main is ZERO. The sweep's decision inputs -- profiles_conflict/ ability_rw_profile (ability_rw.rs), build_resolved_from_def (ability_utils.rs), and the allowlist (triggers_ordering_parity_tests.rs) -- are byte-identical to main and the full-DB path never calls the (unmodified-body) group_is_order_independent, so the run equals main's own full-DB result. It reports 20 PRE-EXISTING over-prompt divergences (the Urza's-era hidden/veiled/opal/lurking enchantment cycle) that predate this change; all are the safe auto->prompt direction (zero under-prompts => zero CR 603.3b violations). Default CI runs the fixture path (unexplained=0). These are a pre-existing full-DB-only allowlist gap, out of PR-7 scope, tracked as a follow-up. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): live loop-shortcut protocol + APNAP response window (PR-7 phase 3) Phase 3 (Part A) of combo-detector PR-7: at a CR 704.3 priority point an opt-in LoopDetectionMode::Interactive routes a confirmed live loop certificate through a shortcut-offer protocol instead of the pre-feature halt. Default (Off) and On stay byte-identical (samples() == is_on() at both live gates; the On reconcile arm is byte-verbatim inside the mode match). Bridge (interactive_loop_bridge): on a determinate lethal single-winner drain, mandatory loops auto-win (identical to the On path); optional loops OFFER WaitingFor::LoopShortcut to the determinate winner (CR 732.2a), then an APNAP WaitingFor::RespondToShortcut accept-or-shorten window over all living opponents (CR 732.2b/c) reusing the OpponentMayChoice remaining_players drain-one-advance fan-out. GameAction::DeclareShortcut{count,template} + RespondToShortcut{response} drive it. CR 732.4 all-mandatory net-progress no-loss loops end in a draw (CR 104.4b); any loss axis or optional loop falls through to the pre-feature halt. Multiplayer (>=3p APNAP) from the start. Winner authority: the crown is the sole non-faller from live_mandatory_loop_winner (nonfallers.len() == 1 requires every OTHER living player to fall, CR 104.2a), not the mechanical loop controller. Subset-lethal pods (an opponent survives) return None and never crown; a >=2-faller simultaneity floor requires equal per-cycle life deltas so all fallers cross lethal in one CR 704.3 SBA batch. Soundness (CR 608.2b): the offer latches the determinate winner; the dispatch firewall admits only DeclareShortcut/RespondToShortcut between offer and accept (a live ring re-scan is unsound -- intervening finalize/SBA/layer steps drift the paused state). Concede and Debug bypass that firewall, so apply_confirmed_shortcut re-validates the latched controller's liveness at consumption: CR 104.3a (a player who conceded has lost and cannot be crowned), CR 104.2a (the winner must still be in the game), CR 800.4a (the departed proposer's loop objects have left, so the loop dissolved). On a departed proposer it hands priority to the next LIVING player (is_alive(active_player) ? active_player : next_player_in_turn_order) rather than crowning a departed player or leaving priority on a departed holder; the APNAP remaining_players advance likewise filters out opponents who left mid-window. New serialized surface (LoopDetectionMode::Interactive, WaitingFor::LoopShortcut/ RespondToShortcut, GameAction::DeclareShortcut/RespondToShortcut, IterationCount::UntilLethal, ShortcutProposal/ShortcutResponse, LoopCertificate serde derives) is additive; Off/On configs serialize byte-identically. Frontend modal UI, smart-Shorten AI, and Fixed-count materialization are Phase 4/5 (the WaitingFor types are registered so UnhandledWaitingForModal does not fire; the AI answers RespondToShortcut with Accept and DeclareShortcut with UntilLethal via explicit non-wildcard arms). Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p engine (18616 passed, 0 failed); loop_shortcut 10/10 -- On+Off byte-identity goldens, 3p APNAP cascade accept-win, CR 732.4 draw, Shorten priority window, authorization routing, and revert-failing guards: controller-concede-not-crowned, queued-opponent-concede-no-deadlock, and 3p-subset-lethal-no-crown. FORGE ordering_parity_sweep not run -- Phase 3 touches zero trigger-ordering surface; the three decision-input recipe files (ability_rw.rs, ability_utils.rs, triggers_ordering_parity_tests.rs) are byte-identical to the branch base and no parser/card-data is touched (delta=0 by construction). Independent review-impl clean after catching + fixing one soundness blocker (the Concede/Debug firewall bypass and its dead-priority remedy path). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): object-growth loop detection — offline detector (PR-7 phase 4a-core) Phase 4a-core of combo-detector PR-7: the OFFLINE object-growth loop detector — the object-axis analog of the existing stack-axis omega-coverability. Certifies (or fail-closed rejects) loops whose battlefield GROWS by inert tokens each iteration, which the strict board-equality gate + MAX_OBJECT_GROWTH ceiling could not detect. Offline only: no reducer/sampler/bridge change, so Off/On/Interactive runtime stays byte-identical to HEAD; the live empty-stack sampler (4a-live) is deferred until a certifiable live object-growth loop exists (plan ruling). Detector (analysis/resource.rs) loop_states_cover_modulo_object_growth reuses the Karp-Miller discipline on the object axis: - board_covers: absolute-ObjectId embed + inert-class confine; grown_ids = the battlefield after-before absolute-id set. - eq_except_growable: strip grown objects + clear battlefield/stack, then reuse the GameState PartialEq wholesale so EVERY non-object field strict-compares (incl. delayed_triggers / deferred_triggers / pending_trigger* / epic_effects accumulators) — the excepted set is derived, not hand-listed. - grown_objects_are_inert: no static/trigger/replacement/activated ability, non-CDA P/T, non-legendary/world, no counters (CR 704.5). Wired into the OFFLINE detect_loop classifier; the fail-loud residual_board_delta seam lights up on object growth. Fail-closed firewalls (CR 732.2a predictable-results; false-negative = grind-to-cap = today's behavior, false-positive is not acceptable): - Observation (fire_time_conditions_read_growing_class): scans the CONDITION and full EFFECT-BODY AST of every trigger/activated/static/TCE/granted-keyword ability on every battlefield object + the delayed/deferred/pending/epic store bodies, on the projected||sibling axis via the recursive scan_effect (opaque => CONSERVATIVE). - Cost (cost_surface_references_growing_class): ONE predicate over the whole cost surface — an EXHAUSTIVE no-`_` match over all 117 StaticMode variants (ModifyCost / ReduceAbilityCost dynamic_count for Reduce AND Raise; the AbilityCost-bearing Impose/Alternative/CastWith variants + the three cast-permission variants; CastWithKeyword) and an EXHAUSTIVE no-`_` match over all 198 Keyword variants (Affinity/Convoke/Improvise/Delve/Emerge/Offering/Bargain/Assist/Crew/Saddle/ Station/Teamwork/Conspire/Waterbend/Harmonize/Craft/Casualty reject on grown-class overlap; Undaunted is opponent-count SAFE), plus nested sub_ability/else_ability cost recursion. A per-creature cost INCREASE (ModifyCost Raise + ObjectCount) is the false-positive-infinite direction and rejects. Both matches are compile-break tripwires — a future StaticMode/Keyword variant cannot silently fail open. Totality guards: compile-time _gameobject_partition_is_total (all 136 GameObject fields) + _gamestate_partition_is_total (all 259 GameState fields), no `..`, so a future field is a build break until classified. objects_content_eq is extended with the 14 mutable per-object accumulators the hand-list had drifted behind (chosen_attributes / intensity / stickers / perpetual_mods / class_level / modal_back_face / goaded_by / detained_by / casting_permissions / saddled_by / ...), each classified by its write sites, not its doc-string. Stricter equality on the shared 2p CR 104.4b path is fail-safe (only suppresses a wrongful draw where an accumulator differed = not a true fixed-point); T-ON-golden stays byte-identical. Human rulings (plan section 14): keystone Witherbloom + Sprout Swarm => FAIL-CLOSED REJECT (cast-affordability preservation is unprovable from resolution deltas — affinity monotonicity is necessary but insufficient without a convoke-supply invariant the ResourceVector does not capture); object growth certifies nothing LIVE (4a-live deferred; keystone ships as the offline structural K-offline REJECT); B5 defuse tracks every enabler (phase 4c). Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p engine (18642 passed, 0 failed); 20 offline object-growth predicate tests incl. K-offline keystone REJECT + R-e2 cost-increase-infinite + previously-fail-open cost keywords + one REJECT per observation/cost axis, each with a paired COVER control; T-ON-golden (Heliod+Ballista live 2p CR 104.4b) green — the fail-safe proof the objects_content_eq ADD did not over-suppress a legitimate draw loop; full loop suites green (loop_check/resource/loop_shortcut/corpus). Both firewall matches exhaustive-no-`_`. FORGE ordering-parity recipe files byte-identical (zero trigger-ordering/parser/card-data surface). Independent review: a 6-round soundness plan-review (S1-S6, each a fail-open-allowlist / equality-loosening class) + an implementation review that caught + structurally closed two cost-firewall gaps the green tests masked (the StaticMode type-misread and the cost-keyword fail-open allowlist). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): finite Fixed(N) loop-shortcut materialization (PR-7 phase 4b) Materialize a confirmed Fixed(N) loop shortcut (CR 732.2a) by driving N whole cycles of the constant-depth loop, committing atomically per cycle. Three-way per-beat drive-outcome split: cross-lethal (CR 704.5a) commits the GameOver already applied to `work` and stops; a recurred settle beat commits the cycle; any other prompt or engine error aborts to manual play (last complete cycle + priority to the living seat, CR 800.4a). Per-iteration `resolve` re-check enforces target legality (CR 608.2b) and object incarnation (CR 400.7) against the last committed board; stale/absent source aborts. Threads an Option<DecisionTemplate> onto ShortcutProposal (serde default, skip-if-none, so On/Off serialized streams are unchanged). The Fixed arm is reachable only under LoopDetectionMode::Interactive; Off/On paths stay byte-identical (candidates.rs still emits only UntilLethal). Per-beat fresh event buffers avoid re-scanning prior beats' events, matching run_auto_pass_loop. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase 4c — revocable-∞ (B5) + LOW-2 self-preserving shortcut CR 104.4b: an OPTIONAL beneficial (non-winning) loop is neither crowned (Path A: no faller) nor drawn (Path B: !mandatory) — mark it as a revocable-∞ capability (Path C) with its battlefield enabler set, so an enabler's departure (zones.rs apply_zone_exit_cleanup) REVOKES it. LOW-2: the AI's RespondToShortcut self-preserves via the shared smart_shortcut_response authority. Documents has_no_loss_axis's two-gate asymmetry (measured): REDUNDANT at Path C (poison caught by ==Advantage/PoisonLoss, life by is_net_progress, library by recurrence — discriminator unsatisfiable, waived) but LOAD-BEARING at Path B (sole loss-axis veto, no ==Advantage backstop — kept; a poison loop reaching the gate would be wrongly drawn without it). The Path-B runtime discriminator is waived as measured-unsatisfiable: no constructible fixture carries poison>0 to the gate — the 2-trigger form clears the loop-detect ring on OrderTriggers beats, and the single-compound-trigger form drops the poison conjunct via a parser gap. Ships the Path-B draw-gate behavioral test (control draws / variant poisons out). Follow-up (LOW, 0 live cards, synthetic-only): silent-clause-swallow parser gap — a bare-"and" cross-subject second conjunct ("...and each opponent gets a poison counter") is dropped at parse (kept only in description, not Unimplemented); fixing it unblocks a genuine Path-B has_no_loss_axis runtime discriminator. Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — classify post-rebase GameState fields for the loop cover gate Rebase of PR-7 phases 1–4c onto upstream/main d1a1e995e (#5546) surfaced two new GameState fields that the object-growth cover gate must account for. The `_gamestate_partition_is_total` totality guard (exhaustive no-`..` destructure) forces an explicit classification of every field; ONE-SIDED-SAFETY governs it (COMPARED is fail-safe, EXCLUSION is the fail-dangerous direction — exclude a field only when comparing it would break legitimate loop detection): - pending_player_scope_sacrifice_choice: COMPARED (already in upstream's `impl PartialEq`) — a differing paused-sacrifice state is correctly not a fixed-point repeat. - post_replacement_token_substitution_count (CR 614.1a copy-token count): COMPARED via one contained conjunct in `eq_except_growable` — upstream's PartialEq deliberately excludes it, but excluding a count from the cover gate is the fail-dangerous direction. It is None at every sample beat (cleared whenever waiting_for == Priority) or a constant direct-assigned count across a real copy-token loop, so comparing never suppresses a legitimate loop. Upstream's PartialEq is left untouched. - resolution_source_relatch (CR 400.7j self-move re-latch): EXCLUDED-required (measured by ordering trace, not doc-trust). The clear at stack.rs fires at the START of the next resolution; record_loop_detect_sample fires at the Priority window AFTER this resolution's self-move set it, so at the sample beat it holds this iteration's current_incarnation, which bumps every iteration. Comparing it would make every self-moving loop compare unequal (a false negative). Object growth lives in `objects` (stripped and compared by eq_except_growable), so excluding this single-object identity field cannot hide growth. Gate: fmt, clippy --workspace --all-targets -D warnings, test -p engine -p phase-ai (0 failed / 22 binaries), FORGE byte-id (ability_utils/ability_rw byte-identical) all green; 0-behind upstream/main. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase 4d-i — offline tapped-fodder cover + BLOCKER-2 structural sign-check Offline soundness core for object/token-growth loop detection (Witherbloom, the Balancer + Sprout Swarm class). NO live caller — the fodder predicate is exercised only by unit tests + the T-B1i discriminator; 4d-ii wires the live clone-drive hook + materializer. LoopDetectionMode stays OFF ⇒ byte-identical to pre-PR-7. New (analysis/resource.rs): - loop_states_cover_modulo_fodder_growth (pub(crate)) + board_covers_modulo_fodder + fodder_content_eq + is_fodder — tapped-split multiset cover for inert fodder growth (untapped(cur) >= untapped(prior) + strict total growth); stable-engine content via objects_content_eq (sole authority — GameState PartialEq is objects.len()-only). Drops the abstract cost-surface firewall (driven-path-only); detect_loop STAYS on loop_states_cover_modulo_object_growth (pinned by T-B1i). - driving_resources_non_decreasing + projected_player_axes + project_out_player_consumables refactor (no-`..` compiler-total destructure shared with project_out_resources) + _projected_player_axes_is_total — BLOCKER-2 structural sign-check: blanket fail-closed veto on any controller-side decrease of a projected consumable (energy/poison/per-kind player_counters + per-kind monotone object counters), closing the project_out_resources sign-unchecked hole. CR 106.1/119/122.1/122.1a/ 306.5c/310.4c/606.3/613.4c (all grep-verified). Tests: 15 inline (fodder cover + siblings; the 8-fixture sign-check family incl the structural per-kind player_counter discriminator; _projected_player_axes_is_total) + T-B1i (detect_loop returns None on a convoke-fodder pair; asserts both None AND fodder-cover==true). Reject-preservation regressions object_growth_k_offline_* / object_growth_r_a2_* stay green. Gate (direct cargo, worktree): fmt clean; check; clippy --all-targets -D warnings 0 warn; test -p engine 16175 passed / 0 failed. Plan and impl each reviewed clean by independent agents. 4d-ii carries (flagged, unreachable in 4d-i — no live caller): (1) tie the map-consumable sign-veto to the projected destructure (a projected_player_maps helper from the same no-`..` site) before wiring the live caller, so a future 2nd map consumable cannot be projected-out-and-sign-unchecked (BLOCKER-2 one field over); (2) veto controller-side damage_marked INCREASE (CR 704.5g) once object_resource_axes_match is dropped on the driven path. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase 4d-ii — live token-growth loop detection + CR 732.2a offer Detect infinite token-growth loops live end-to-end and offer the CR 732.2a shortcut. Acceptance bar (the 51st): Witherbloom, the Balancer + Sprout Swarm parses, casts (convoke + affinity + buyback), detects, OFFERS, and materializes N real untapped Saprolings on Accept. Foundation (injector-independent): - RecastContext + GameState.last_recast_context. EXCLUDED from impl PartialEq so the live CR 104.4b 2p-draw comparison is byte-identical to pre-PR-7; compared only via explicit cover conjuncts (eq_except_growable + loop_states_equal_modulo_resources), keeping the N7 discriminator non-vacuous. - projected_player_maps: no-`..` structural tie for map-typed player consumables (a future map consumable build-breaks); damage_marked-INCREASE veto (CR 704.5g). - triggers.rs trigger-order filter_map made exhaustive (future PinnedDecision variant build-breaks). Live-drive: - PinnedDecision::ConvokeTaps (CR 601.2h + CR 702.51a/b) with select_convoke_taps as the single convoke cost-selection authority (the ConvokeTaps replay routes through it; shares is_convoke_eligible/object_cant_tap; no parallel path). - drive_recast_iteration injector: exhaustive on WaitingFor, fail-closed (Err(RecastAbort)) on every unpinned prompt; the ManaPayment decision loop is an exhaustive non-`_` match so a future ConcreteDecision variant build-breaks. - try_offer_object_growth_shortcut hook: read-only &GameState (live write type-impossible); OFFERS via WaitingFor::LoopShortcut, never auto-resolves (CR 732.2a); SimulationProbeGuard spans both drives (no hook recursion). - Materializer third disjunct (loop_states_cover_modulo_fodder_growth) + Fixed(N) object-growth cycle; leaves a valid state (right controller, ring cleared, last_recast_context cleared, priority to next living seat CR 800.4a). - RecastContext capture gated on loop_detection.samples() — #4603 opt-in gate: in default LoopDetectionMode::Off nothing is written, so the serialized surface is byte-identical to pre-PR-7. Tests (real Witherbloom + Sprout Swarm, no synthetic on the positive): - P1 offers live (LoopShortcut, TokensCreated cert, exactly one real Saproling from the single real cast); P2 materializes N on Accept. - N1/N3 reject (no affinity / no buyback); N6 live no-offer control (damage-drain recast, CR 704.5g branch d) with a positive reach-guard, revert-probed. - off-mode #4603 gate (OFF is_none + ON reach-guard, revert-probed); select_convoke_taps x4 + convoke_pin. - The 51st loop body has NO auto-resolved randomness (vanilla Saproling, no ETB, deterministic convoke) — runtime-confirmed by P1. N4 (energy) / N5 (player-counter) no-offer controls are covered at the unit + structural-wiring level, not live fixtures: a live per-recast drain on these axes is genuinely infeasible in today's buyback-recast mechanism (an energy cost breaks buyback recurrence — the naive live test was proven vacuous by revert-probe and removed rather than shipped; no engine effect drains Experience/Ticket counters). The shared driving_resources_non_decreasing seam (branches a/b/d) is live-verified via N6. The branch-(a)/(b) sign-checks are fail-closed defensive guards, live-unreachable today but not dead code. The offer path is fail-closed-modulo-auto-randomness until the A2 determinism gate (separate follow-up pass). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase 4d A2 determinism gate — reject randomness-bearing recast loops (CR 732.2a) A CR 732.2a shortcut "can't include conditional actions, where the outcome of a game event determines the next action" — so an object-growth loop whose recast cycle draws game randomness must NOT be offered as a fixed-count shortcut. This wires a two-layer fail-closed determinism gate into the live offer path (`try_offer_object_growth_shortcut`), discharging the b132ad9f8 "fail-closed-modulo-auto-randomness" carry. (a) Static, compile-time-exhaustive scan of the recast spell body before driving: `effect_is_randomness_bearing` (a wildcard-free match over `Effect` — a future random-bearing variant BUILD-BREAKS rather than being silently offered) + `spell_ability_bears_randomness`; the `collect_effects` walker gains the two nested `replacement_effect` arms it was missing. Covers auto-resolved coin (CR 705.1) / die (CR 706.1a) and the field-level "game selects at random" selections (CR 701.9b) via `is_random()`. (b) Post-drive RNG stream-position backstop: the driven clone starts as an equal `state.clone()` (ChaCha20 derives `Clone`, preserving word position), so `s_n2.rng.get_word_pos() != state.rng.get_word_pos()` iff any randomness was consumed during the deterministic detection drive — from ANY source, including external triggered/replacement randomness the static scan can't see. Strictly-more-conservative (only turns OFFERs into NO-OFFERs). Soundness (measured): external coin/die/`Choose` are already rejected by the fodder cover (`scan_effect => Axes::CONSERVATIVE` reads the growing sibling axis); the recast spell body is NOT scanned by the cover, so (a) is the gate there. (b) is the universal runtime backstop. The custom-classified random card-selections (`Discard`/`RevealHand`/`ChooseFromZone`) can classify `sibling=false` and pass the cover, but each draws RNG inline inseparably from an RNG-outcome-dependent object move/mark — breaking byte-identical loop reproduction — so no reachable input makes (b) the SOLE gate today; (b) is a complete future-proof backstop rather than a currently-isolable path. Verified by an independent /review-impl pass (CLEAN-FOR-COMMIT). Tests: `object_growth_random_recast_body_does_not_offer` (recast-body coin → no offer; coin-free twin shell still OFFERs, proving the input reaches the offer path and isolating the coin as the sole disqualifier) + leaf tests discriminating `is_random()` on both selection-mode types. The paired positive `object_growth_51st_sprout_swarm_covers_and_offers` is unchanged. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase G2 — loop-detect ring survives a multi-trigger OrderTriggers beat (CR 603.3b/732.2a) A self-refilling mandatory loop whose cycle emits 2+ simultaneous distinguishable triggers parks at `WaitingFor::OrderTriggers` each iteration (CR 603.3b). The loop-detection ring was WIPED on that beat by two clears, so it never accrued the >=2 samples CR 732.2a detection needs — multi-trigger loops were undetectable while single-trigger loops (which settle at Priority each cycle) worked. Ordering simultaneous triggers is a forced step of putting them on the stack, not a deliberate cascade-break, so the ring must survive it. Two match-arm edits in game/engine.rs (no new variant/field): - SEAM1 (pass_priority_once_with_pipeline): guard the ring-clear `else` with `!matches!(wf, WaitingFor::OrderTriggers { .. })` — settling into the mandatory ordering window leaves the ring intact (the record path stays gated on Priority{active}; the triggers are staged off-stack in pending_trigger_order, so the stack is momentarily shrunk here). Every other settle (drain-to-empty, shrinking stack, interactive non-Priority window) still clears. - SEAM2 (apply_action): exempt `GameAction::OrderTriggers` from the deliberate- action clear (`!matches!(action, PassPriority | OrderTriggers { .. })`) — the ordering response continues a mandatory cascade. Every other non-Pass action (cast/activate/play-land) still invalidates the ring. SetTriggerOrderTemplate early-returns before this clear, so it is unaffected. Both edits are required: the ring is wiped at two moments of one cycle — SEAM1 when the resolution settles into the window, SEAM2 when the window is answered. Test (mechanism-scoped): drive_multi_trigger_ring_survives_order_triggers_beat asserts an OrderTriggers beat occurs (reach-guard) + max_ring>=2 (ring survival). Revert-probe: reverting EITHER seam alone drops max_ring 2->1 (and the measured end-to-end GameOver Some(P0) -> None), proving both seams load-bearing. Adds a drive_with_trigger_ordering helper, an idx18 single-trigger no-order-beat non-regression, and a no-refiller false-positive control. The fixture also reaches end-to-end GameOver at beat 10 (measured, documented as a NOTE not asserted — max_ring==2 sits at the 2-sample detection edge; the robust E2E multi-trigger witness is the 52nd/G1). Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — classify StaticMode::CantBeBlockedUnlessAllBlock in the loop cover gate Rebase onto upstream/main (5efd9db32) surfaced a new `StaticMode` variant (`CantBeBlockedUnlessAllBlock`) via the exhaustive no-`_` match in `static_mode_references_growing_class` (analysis/resource.rs) — the cover-gate cost-surface scanner a new variant must be classified in before it compiles. It is a combat blocking-restriction static with no cost surface, so it reads no growing-class resource → classified read-free (`=> false`), alongside its siblings CantBeBlocked / CantBeBlockedExceptBy / CantBeBlockedByMoreThan / MustBeBlockedByAll. The 11 PR-7 commits rebased patch-identical (0 conflicts). Full verify green: check + clippy --all-targets (0 warn); test -p engine lib 16237 + integration 2713, 0 failed (partition-lock=53, loop suites, on_shortcut_byte_identical_to_pre_pr7_golden). FORGE + coverage N/A (delta touches neither ability_rw nor the parser). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 52nd/G1 — live poison-loss loop-winner + per-victim ResourceAxis::Poison re-key (CR 704.5c) G1 — generalize the live mandatory-loop-winner to the poison axis. The faller partition now recognizes a per-cycle poison-accruing player (delta.poison[p] > 0) alongside life-loss (CR 704.5a / 704.5c); the aggregate-poison firewall is removed; classify_win_kind reads the per-victim field AND the static .counters path (dual authority for the live + candidate-graph callers); the gate accepts LethalDamage | PoisonLoss; a poison-faller simultaneity guard (equal per-cycle delta AND equal absolute poison at cycle_end among fallers) closes a >=3p staggered-poison fail-open (CR 704.3). Option A re-key (mandated by the resource.rs / derived_views.rs guard-notes) — the analysis poison axis is re-keyed by victim PlayerId: new ResourceVector.poison: BTreeMap<PlayerId, i64> + ResourceAxis::Poison(PlayerId) (a per-victim parameterization mirroring Life(PlayerId), not a proliferated sibling); attribution_player victim-routes Poison(p) => p; has_no_loss_axis and the From<&ResourceAxis> for AxisKey drift-gate follow. Both guard-notes discharged. 3 display-only frontend edits (ResourceAxis union/tag + HUD counters family). Two-path witness architecture — the real Kilo/Freed/Relic activation combo is covered by the offline certification driver (drive_offline_kilo_freed_relic); the live equality-sampler cannot see a player-driven activation loop by construction (the stack drains between activations => the ring-sample CLEAR arm fires => the ring never accumulates a recurrence). A synthetic self-refilling drain-poison trigger (interactive_poison_axis_surfaces_in_offer_certificate) E2E-proves the re-keyed Poison(PlayerId) axis reaches a live offer certificate (real recurrence; G-5 revert-probe flips it). The win_kind==PoisonLoss full-drive and the Path-B runtime draw-veto discriminator are waived as measured-unreachable (proliferate's ProliferateChoice beat clears the ring) — G-15 kept load-bearing plus the in-code proof; the novel faller/classify logic is covered by loop_check.rs unit tests. Verification: cargo fmt; clippy -p engine --all-targets -D warnings clean; cargo test -p engine 16240 lib + 2714 integration, 0 failed; Off/On golden byte-identity green; frontend type-check + lint clean; coverage baseline unchanged; FORGE N/A (no ability_rw / same-event ordering surface). All CR annotations grep-verified against docs/MagicCompRules.txt. Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — Effect::ForEachCategory in the A2 randomness scan Main parameterized Effect::ForEachCategoryExile into Effect::ForEachCategory { action: ForEachCategoryAction } (a "parameterize, don't proliferate" refactor). Retarget the exhaustive Effect match arm in effect_is_randomness_bearing (ability_scan.rs) — ForEachCategory is deterministic category iteration, so it stays in the non-randomness group. The import union (AbilityCost / AbilityDefinition / ContinuousModification from PR-7 phase 4a-core + main's new ForEachCategoryAction) was resolved in-place during the rebase. Rebase of feat/combo-detect-pr7 (13 commits) onto upstream/main a61e7fdd9: one textual conflict (the ability_scan.rs import list) plus one semantic drift (this rename — the patch applied cleanly but referenced the removed variant, surfacing at verification not conflict). Full re-verify green: clippy -p engine --all-targets -D warnings clean; cargo test -p engine 16252 lib + 2718 integration, 0 failed; Off/On golden byte-identity preserved. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): PR-7 gate-relax item-4 — Typed-filter drains read projected axes The loop-cover gate's item-4 (`stack_entry_reads_projected_resource`) rejected every `TargetFilter::Typed`-targeted drain via a blanket `scan_target_filter(Typed) => Axes::CONSERVATIVE` (projected:true unconditionally). Combined with item-3's raw `targets.is_empty()` coarseness (COMMIT 2), this co-dominated the rejection of escalating targeted-drain loops (Vito, Thorn of the Dusk Rose), making them undetectable. Refine ONLY the `.projected` field of the `Typed` arm to `typed_filter_reads_projected(tf)` — the event/sibling axes are kept literal `true` (byte-preserved). New exhaustive `scan_filter_prop` classifier (no `_` wildcard, unknown => CONSERVATIVE): QuantityExpr/TargetFilter/PlayerFilter-bearing props recurse; ControllerRef-bearing recurse (every outcome projected:false); `CountersPutOnThisTurn`/`ControllerChoseLabel` fail-closed CONSERVATIVE. Ground truth: a prop is projected iff `project_out_resources` clears the field its runtime eval reads. `WasDealtDamageThisTurn` (eval reads `state.damage_dealt_this_turn`) and `ZoneChangedThisTurn` (eval reads `state.zone_changes_this_turn`) are both cleared by `project_out_resources` and NOT strict-compared by `object_resource_axes_match` (which compares only `damage_marked` + `counters`) — classified CONSERVATIVE (CR 120 / CR 400 / CR 603.6a). The variant doc's "damage_marked > 0" was stale; runtime eval reads the journal. The remaining "this-turn" leaves (`EnteredThisTurn`/`Attacked`/`Blocked`/`ControlledContinuously`) read non-cleared object/combat fields => NONE. cfg-test flagged-set shrinks {Dethrone,Increment,Soulbond,Training} => {Dethrone,Increment}: the refinement clears Soulbond/Training fail-closed false-positives (their Typed filters carry no projected prop). Verify: clippy -p engine --all-targets clean; lib 16260 + integration 2719, 0 failed; all 5 typed_filter_* classifier discriminators pass. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): PR-7 gate-relax item-3 — forced-unique targets clear the ordering-input gate The loop-cover gate's item-3 (`stack_entry_has_no_ordering_input`) read raw `ability.targets.is_empty()`, rejecting any non-empty-target stack entry as introducing ordering freedom. That is too coarse: a forced-unique target (the sole legal assignment) offers no ordering choice. Combined with item-4's Typed-filter projected-axis blind spot (COMMIT 1), the two conjuncts co-dominated the rejection of escalating targeted-drain loops. Widen `stack_entry_has_no_ordering_input`: a non-empty-target entry passes iff `forced_unique_targeting(state, ability)` holds — `build_target_slots` + `auto_select_targets_for_ability(...) == Ok(Some(_))` (exactly one legal assignment; fail-closed on Err / >=2 candidates / empty slots). CR 603.3d / CR 608.2b: a determinate single-target ability contributes no resolution-choice branching to the loop cover. Vito, Thorn of the Dusk Rose 2-player determinate-win now EMPIRICALLY detects and offers the CR 732.2a shortcut. Discriminator = `life(victim) > 0`: early omega-cover detection leaves the drained opponent positive, whereas losing detection grinds them to <= 0 via natural resolution (CR 704.5a), which also wins P0 — so `GameOver{P0}` alone is not discriminating. Negative controls: `n1_open_target_growing_still_rejected` (open/non-unique targets still reject) and `item6_still_vetoes_under_forced_unique_targets` (the resolution-choice axis, item-6, still vetoes independently under forced-unique targets). Verify: clippy clean; integration vito_bond_conqueror_2p_determinate_win + n1_forced_unique_targeted_cover_true + 2 negative controls pass; both per-conjunct revert-probes flip (item-3 revert => no-detect; item-4 revert => no-detect). Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — classify AbilityCost::UnattachFrom in the object-growth cost scan Rebase of feat/combo-detect-pr7 (16 commits) onto upstream/main 1c3eee9dd (12 new commits incl. Captain America, First Avenger #5552 + release v0.22.0). One drift: main's Captain America commit added `AbilityCost::UnattachFrom { .. }` (unattach a named permanent as an activation cost). The Phase-4d object-growth cost scan's `scan_ability_cost` (ability_scan.rs) is a no-wildcard exhaustive match, so the new variant surfaced at compile (E0004), not as a textual conflict. `UnattachFrom` is a fixed structural cost with no dynamic board read and no projected-resource drain — classify it `Axes::NONE` alongside the existing `AbilityCost::Unattach` (main categorizes both as `CostCategory::Unattaches`). Every other exhaustive AbilityCost match in the engine already covers the variant (main-added or pre-existing); only this Phase-4d scan needed the arm. Full re-verify green: clippy -p engine --all-targets clean; lib 16272 + integration 2750, 0 failed. The two commits the 3-way merge re-anchored (phase-4b `apply_confirmed_shortcut` context; phase-4d-ii recast capture following main's `finalize_cast_with_phyrexian_choices` -> `_inner` split) are semantic-guarded green by b3_materialize_* and object_growth_51st_*; Vito gate-relax E2E + poison-axis E2E pass. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): PR-7 53rd Pentad — shared strict-growth Generic-counter loop-cover predicate Cover the infinite charge-counter-growth proliferate loop (Pentad Prism + Kilo/Freed/Relic) and offer the CR 732.2a resource shortcut. New sibling predicate `loop_states_cover_modulo_counter_growth` (analysis/resource.rs): strict-growth-only over the PRESERVED `Generic` object-counter axis, fail-closed. - `CounterGrowthDisposition {StrictGrowth, Stable, Consumed}` (typed, not bool). `classify_generic_counter_growth` is a wildcard-free `CounterType` match — the per-type classification table itself (a new variant won't compile until classified), kept in lockstep with `is_monotone_loop_resource`. `Consumed` (any `Generic` counter fell) takes precedence over `StrictGrowth` (mixed grow+consume rejects) — the infinite-consume soundness trap. - `equalize_generic_counters` overwrites only `Generic` counts with `prior`'s, then rides the existing `loop_states_equal_modulo_resources`, so any non-`Generic` drift still rejects via the untouched equality. Wired at exactly two non-GameOver seams (the firewall): `detect_loop` gate-1 (offline `Advantage` certification) and `interactive_loop_bridge` Path-C (live revocable-unbounded capability mark). Deliberately NOT wired into `live_mandatory_loop_winner` (GameOver-capable) — a conservative fail-closed boundary. A charge/burden growth loop classifies `WinKind::Advantage` (CR 104.4b: an optional loop is not a draw), so an over-claim is a declinable offer / revocable mark, never a wrongful game-end — the revocability soundness bound (the equalize step introduces a new projected `Generic`-counter axis, sound by this bound, not by firewall parity). GENERAL over preserved-`Generic` growth: Pentad Prism (charge) and The One Ring (burden) are the SAME cover, so One-Ring's growth cover is discharged here — its later pass is offer-layer + card-verify only. Two-path coverage (matches the existing architecture): the real proliferate loop is offline-certified (`drive_offline_pentad_prism`, real Kilo/Freed/Relic + Pentad seeded >=1 charge via Sunburst, CR 702.44a); the live Path-C disjunct is exercised by a sampler-visible self-refilling charge-growth trigger — the live equality sampler records only non-shrinking-stack cascades, and a `ProliferateChoice` beat hits the pre-existing ring-clear arm. 8 discriminating tests (4 unit + #5/#8 offline + #6/#7 live), all non-vacuous: the consume control (#2) is a same-`Generic("charge")` decrease that flips only under a direction-blind revert; #8 asserts `Some(Advantage, Counter(Other,Other))`; #6 marks the counter axis without any GameOver; #7 proves #4603-OFF byte-identity. Verify: clippy -p engine --all-targets 0/0; analysis lib 173 + loop_shortcut 28 + the 8 new tests green. Plan-reviewed + impl-reviewed clean. Assisted-by: ClaudeCode:claude-opus-4.8 * test(engine): PR-7 54th Walking Ballista — offline LethalDamage acceptance + >2p win-authority controls Walking Ballista's infinite-damage combo (proliferate the +1/+1 counters on the Kilo/Freed/Relic mana-neutral engine, then remove-to-ping) is already covered by existing machinery — this pass adds only acceptance + guard tests, no production change. Rationale (measured, twice-reviewed): - The +1/+1 counters are MONOTONE, so `project_object_for_loop` strips them and the existing `loop_states_equal_modulo_resources` gate-1 already certifies the loop with the damage/life axes as the movers (unlike the 53rd Pentad's PRESERVED `Generic` charge, which needed the new counter-growth cover). - `classify_win_kind` already returns `LethalDamage` for opponent `damage_dealt > 0`. - The loop is offline-only: every cycle contains an `ActivateAbility` (Relic tap, Freed untap, ping) and `apply_action` clears `loop_detect_ring` on every non-PassPriority/OrderTriggers action, so the live GameOver-capable seams are never reached. Ballista's `LethalDamage` is an OFFLINE cert (same class as the 52nd Kilo offline PoisonLoss), never a live game-end. So NO live-lethal offer and NO >2p damage-distribution pin are built here (both are unreachable for the real card; the distribution pin belongs to the future combo-declaration UI pass, whose cert application must route through the all-opponents-fall win-authority). Tests (test/harness-only; the driver is `#[cfg(any(test, feature = "combo-verify"))]`, absent from `DRIVERS`/`CORPUS`, so the 53/12/4/37 partition is unchanged): - `drive_offline_kilo_freed_relic_ballista` — standalone offline driver mirroring `drive_offline_pentad_prism_seeded` (real cards, abilities selected by effect/cost). - N2 `drive_kilo_freed_relic_ballista_certificate` — seed 2 → `LethalDamage` + `covers([DamageDealt(P1)])` + `!mandatory` (a resolved opponent ping is the only way to populate the damage axis). - N3 `drive_kilo_freed_relic_ballista_x0_no_damage_axis` — seed 0 → dead 0/0, the ping activation is rejected (bool-returning `activate_and_resolve`, no panic), degrades to the pure-proliferate `Advantage` loop with no damage axis. - N5 `ballista_mp_single_opponent_ping_no_false_win` (→ None) + `ballista_mp_all_opponents_distributed_ping_wins` (→ Some(P0)) — CR 104.2a: a >2p win requires all opponents to fall; reverting the `nonfallers.len() != 1` guard flips the negative None→Some(P0). (Self-ping→Advantage is covered by the existing `classify_win_kind_controller_only_damage_is_not_lethal` unit control.) Verify: clippy -p engine --all-targets 0/0; the 4 new tests + partition/shape locks (53/12/4/37) green. Plan-reviewed + impl-reviewed clean. Assisted-by: ClaudeCode:claude-opus-4.8 * test(engine): PR-7 One-Ring — opponent-burden proliferate Advantage cert + downstream upkeep-lethal (reuse-only) Test-only pass. The One Ring's Generic("burden") counter grows on an OPPONENT's copy under the Kilo/Freed/Relic infinite-proliferate engine; the combo detector already certifies this via the 53rd Pentad loop_states_cover_modulo_counter_growth cover — ZERO production change. Part A (offline cert): drive_offline_kilo_freed_relic_one_ring — a cfg-gated standalone structural twin of drive_offline_pentad_prism_seeded with The One Ring installed on P1 (opponent). NOT added to DRIVERS/CORPUS (partition 53/12/4/37 held). - seed=1 -> detect_loop = Some(WinKind::Advantage), covers Counter(Other,Other); the burden Counter axis is player-unattributed, so an opponent's burden certifies identically to a controller's charge. - seed=0 control -> Some(Advantage) with NO Counter axis (the loop stays Some via Trigger(Proliferate)) — the runnable discriminator. Part B (downstream lethal): materialize N burden via the counter authority add_counter_with_replacement, advance to the opponent's upkeep, and let the printed .triggers[1] LoseLife(CountersOn(Source,"burden")) fire and resolve. N >= life -> CR 704.5a SBA -> GameOver{winner:Some(P0)} (CR 104.2a). Sub-lethal control (N = life-1) -> opponent survives, no GameOver. CR 701.34a (proliferate), 104.4b (optional loop != draw), 704.5a, 104.2a, 500.6/503.1a (upkeep triggers), 608.2, 122.1, 603.6a, 602.1 verified. Fixture: surgical single-key insert of "the one ring" from the export (one entry; no other fixture entry moved). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): PR-7 combo-declaration UI Stage 1 — ShortcutDecisionSchema on the loop-shortcut offer (engine READ-side) Stage 1 of 3 for the combo-declaration UI (engine schema exposure -> frontend render/collect -> forward-pointer routing). Engine-only, READ-side: exposes a per-viewer decision schema on WaitingFor::LoopShortcut so the frontend (Stage 3) can render an offered CR 732.2a loop's open per-iteration choices and collect pins, computing nothing. A clean parent commit that LOCKS the FE contract. New serde types (analysis/decision_template.rs), the 1:1 read-side dual of the 5 loop-declaration PinnedDecision variants (Order excluded — CR 603.3b trigger-order is not a loop-declaration choice): ShortcutDecisionSchema { iteration_count: IterationCount, points: Vec<DecisionPoint> } DecisionPointKind { Targets{legal_targets}, ConvokeTaps{tappable}, Mode, MayChoice, UnlessBreak } - schema field on WaitingFor::LoopShortcut (#[serde(default)]), populated at both offer sites. build_shortcut_schema CARRIES the detection decision list (build_recast_template output — single authority, no re-derivation); drain offers carry no pins -> empty schema. The only live Stage-1 decision-point is ConvokeTaps; the 4 other builder producers defer to Stage 2 (debug_assert!+None fail-safe — no producer emits them until the targeted-loop gate-relax). - iteration_count: UntilLethal for a determinate CR 704.5a/704.5c drain, else Fixed(1) (an FE-overridable display seed). - MP redaction inside filter_state_for_viewer (CR 732.2a): a non-controller viewer's schema drops hidden-info legal-targets, reusing the existing hand visibility composite keyed on each target object's owner+zone. A structural no-op for Stage-1's only live (public-battlefield ConvokeTaps) set, but the seam + a two-directional revert-probed test lock the security contract before Stage 2 produces hidden-info Targets sets. Tests: T1 live convoke-taps (board-derived, tapped-payer excluded), T2 drain empty+UntilLethal, T3 iteration_count exhaustive over WinKind, T4 redaction (controller keeps hidden target / non-controller drops only it — revert-probe leaks), T6 serde round-trip FE-consumable. cargo check --workspace --all-targets green (phase-ai/wasm/server compile with the new field). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): PR-7 combo-declaration UI Stage 2 — pin ingestion + drive-and-measure win derivation (engine WRITE-side) Builds the engine WRITE-side of the loop-shortcut combo-declaration UI on top of the Stage-1 schema: - validate_pins: fail-closed value-legality firewall (PinValidation), exhaustive over PinnedDecision, hooked at the top of handle_declare_shortcut before APNAP; skipped for choice-free (empty) schemas whose win derivation is pin-independent (preserves the Fixed(N) resolve firewall). CR 608.2b/700.2/732.6. - drive_one_shortcut_cycle + inject_pinned_answer: general mid-drive pin injector (CycleOutcome), extracted behavior-identical from materialize_fixed_shortcut. TriggerTargetSelection re-resolves pins per iteration; the OrderTriggers arm uses the internal reconcile-free apply_action(OrderTriggers) path (never drain_order_triggers_with_identity) so the drive cannot re-enter the offer/crown hook. CR 603.3b/608.2b/702.51a. - E1 crown (apply_until_lethal_shortcut): no longer an unconditional crown. Drives one pin-faithful cycle, measures ResourceVector::delta(snapshot(boundary), snapshot(work)), runs live_mandatory_loop_winner VERBATIM, and crowns GameOver{winner: Some(controller)} only when the measured winner is the proposer — else manual fallback (clearing last_recast_context to avoid an object-growth re-offer livelock). Inherently closes the latent Advantage-loop-declared-UntilLethal mis-crown. CR 704.5a/704.5c/104.2a/800.4a/732.2a/732.2c. - F2 hardening: the >=2-faller crown path also re-verifies fallers_lives_pairwise_equal on the boundary/pre-drive faller lives (the offer's own certification inputs), so a staggered-death unequal-absolute-life drain does not crown. CR 704.3. Tests A-G (loop_shortcut integration + inline stage2_injector) each carry a soundness discriminator with a measured revert-probe and a paired positive reach-guard. Full suite green; cargo check/clippy --workspace --all-targets RC=0. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(client): PR-7 combo-declaration UI Stage 3 — loop-shortcut declare + respond modals (display-only) Frontend for the loop-shortcut combo-declaration UI (engine schema locked by Stage 1/2): - LoopShortcutModal: two display-only modals. DeclareShortcutModal (confirm-only — renders the offer summary + engine-proposed iteration_count + ConvokeTaps eligibility READ-ONLY; dispatches DeclareShortcut{count, template:null}) and RespondToShortcutModal (renders the viewer-filtered ShortcutProposal; Accept / Break-out dispatches RespondToShortcut{response}). Mirror ModeChoiceModal. CR 732.2a/2b/2c. - 5 new TS mirror types (ShortcutDecisionSchema, DecisionPoint, DecisionPointKind, DecisionSlot, DecisionSource) matching the engine serde shapes; the stale LoopShortcut WaitingFor type gains its required schema field. - 3-site actor-gate fix (CR 732.2a, mirrors engine acting_player()): usePlayerId.ts (human render), aiController.ts + p2p-adapter.ts (AI drivers) admit LoopShortcut{controller} into the engine-derived authorized-submitter path — pure routing, no logic. Closes the On-mode AI-controller hang. - Registry migration: LoopShortcut + RespondToShortcut moved PENDING_MODAL_PHASE5 -> HANDLED_WAITING_FOR_TYPES + dispatch-coverage literals; both mounted in GamePage. i18n comboShortcut.* in all 7 locales (parity-gate green). Display-only: every value from an engine schema field, template:null, ConvokeTaps read-only, zero React game-state computation. Pin-capture deferred (rides the >2p targeted lane). T1-T8 discriminating tests; targeted FE gate green (type-check, lint 0-errors, vitest). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(client): mirror GameAction::SetTriggerOrderTemplate in the FE union (PR-7 phase-2 boundary sync) PR-7 phase 2 (commit 67113b225, trigger-order resolver) added engine GameAction::SetTriggerOrderTemplate (types/actions.rs:641) but never mirrored it in the frontend GameAction union, leaving boundary-guardrails.test.ts's engine<->FE lockstep red. Latent because Stages 1-2 were engine-only and the frontend gate never ran on the branch until Stage 3. Serde-faithful transcription mirroring the SetMayTriggerAutoChoice/MayTriggerAutoChoiceOp sibling: SetTriggerOrderTemplate -> TriggerOrderTemplateOp{Save{sources,order}|Remove{key}|ClearAll} -> DecisionGroupKey{sources,kind}/DecisionKind. Pure type mirror, zero logic (no dispatcher/handler — a SetTriggerOrderTemplate UI, if ever wanted, is the trigger-order-resolver feature's concern). boundary-guardrails now green; full FE suite green + type-check clean. CR 603.3b. Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — classify StaticMode::CountersCantBeRemoved in the loop cover gate Upstream #5663 (a21ac2493) added StaticMode::CountersCantBeRemoved (Fear of Sleep Paralysis). PR-7's exhaustive no-wildcard cost-surface scan static_mode_references_growing_class (analysis/resource.rs) must classify it: it is a counter-removal prohibition with no payment cost (counter_type is a filter, not a board read), so its cost surface is read-free -> false, grouped with CountersPersistAcrossZones. Rebase-adaptation only; no behavior change to PR-7's own commits. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): annotate analysis-time zone-clone mutations for the zone-authority census (PR-7 CI) The CI-only engine-authority ratchet (scripts/check-engine-authorities.sh -> zone_authority_census.py; not run by clippy/test, so the local gate was green) flagged two NEW raw zone-container mutations in PR-7 code: - analysis/resource.rs::eq_except_growable — clears battlefield on a DISCARDED comparison CLONE for loop-cover equality. Takes &GameState and mutates a local clone consumed by ==; no gameplay zone event can fire on it. - game/engine.rs::normalize_recast_frame — prunes hand/graveyard/library on a DISCARDED recast comparison-frame CLONE. Takes &GameState and returns a normalized clone; no gameplay zone event fires on it. Both are genuinely non-replaceable analysis-time normalizations (not live gameplay zone changes routed through zone_pipeline), so each site is annotated // allow-raw-zone: <reason> and the frozen baseline (scripts/zone-authority-baseline.txt) is regenerated via --write (2 exempt rows added; no baseline row dropped). Gate B PASS. Comment-only code change; no behavior change. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): address #5672 review — Clash randomness (CR 732.2a), R2 typed enums, FE dead code - Classify Effect::Clash as randomness-bearing: CR 701.30a reveals the top of a shuffled library (hidden info at pin time) and CR 701.30d decides the winner by revealed mana value, so a loop whose recast body contains a clash is not shortcut-eligible under CR 732.2a. Moved the false->true arm in effect_is_randomness_bearing + added a discriminating assertion to randomness_classifier_discriminates (revert-probe: moving it back flips the assertion). - R2 (no bool fields): PinnedDecision/ConcreteDecision take/pay bool -> MayChoiceOption{Take, Decline} / UnlessPaymentOption{Pay,Decline}; RecastContext.uses_buyback bool -> BuybackUsage {Used,NotUsed} with a pays() accessor for the DecideOptionalCost consumer. - Remove the dead-code '?? []' guard in LoopShortcutModal (schema.points is non-optional). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(PR-5672): expose interactive shortcut UI * feat(engine): CR 732.2a LoopShortcut controller-decline + engine-owned convoke count (#5672 review) Addresses maintainer matthewevans' CHANGES_REQUESTED on #5672 (un-holds follow-up #24). BLOCKER (CR 732.2a): the loop winner was forced to propose a shortcut (only DeclareShortcut was accepted at WaitingFor::LoopShortcut). CR 732.2a makes proposing a shortcut a MAY. Add a unit GameAction::DeclineShortcut: the controller-only decline restores ordinary priority (living_priority_seat) and clears the object-growth routing context (last_recast_context) so the post-return reconcile does not re-offer. Seam-1 (interactive/loop_detect_ring) re-offer is already suppressed by apply_action's deliberate-action ring invalidation (a deliberate break clears the ring); the handler owns only the Seam-2 gap. A genuine re-recurrence re-arms the offer. Controller-only authorization is enforced upstream via check_actor_authorization. NON-BLOCKING: move the convoke tappable-count derivation out of React into the engine-owned ShortcutDecisionSchema (convoke_tappable_count, CR 702.51a); the modal renders it directly. Adds a FE Decline button (display-only) + comboShortcut.decline in all 7 locales. Two discriminating end-to-end decline tests (interactive dismissal + object-growth suppression, each with an independent revert-probe) + a wrong-actor auth-reject test. #4603 OFF stays byte-identical (no new GameState field; the offer is never installed when OFF). Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — #5686 legacy_ field renames + drain/draw_sequence adds in the partition guard Rebase onto upstream/main 6f7cee8e8 crosses #5686, which renamed six GameState fields to their legacy_ serde-migration names (post_replacement_{continuation,source,applied,event_source,event_target} -> legacy_*, pending_multi_draw -> legacy_pending_multi_draw) and added post_replacement_drains + draw_sequences. Update the exhaustive _gamestate_partition_is_total destructure (no '..', so a field-set mismatch is a hard E0027/E0559) to the new field names; the two new fields join the destructure so the cover-gate re-audit tripwire stays total. Soundness unchanged: draw_sequences is loop_equal-compared in GameState PartialEq; post_replacement_drains is skip_serializing_if=is_empty (settle-empty, loop-neutral). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(PR-5672): separate shortcut proposer and winner * test(PR-5672): exercise split shortcut authority * fix(PR-5672): rebase game-state totality guard * fix(PR-5672): group shortcut offer inputs * fix(PR-5672): clarify shortcut authorities --------- Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…onsumer surface (phase-rs#5830) Plan 03 step 1's zone-mutation and draw-replacement censuses already shipped (scripts/zone_authority_census.py, scripts/draw_replacement_census.py). The one residual gap was a census for PostReplacementContinuation::{Template, Resolved} construction, installation, stash, and dispatch sites -- the surface the durable draw state machine (phase-rs#5686/phase-rs#5690) built on. Add scripts/post_replacement_continuation_census.py mirroring the existing ratchet pattern (reuses the shared iter_production_lines scanner), freeze the current 26 hits / 22 rows into scripts/post-replacement-continuation- baseline.txt, and wire it into check-engine-authorities.sh as section (D). No new Rust test: production behavior for this surface is already pinned in draw_from_general_post_replacement.rs (Template/Resolved arms + a nested- continuation fixture), so the census only needed to freeze the structural surface those tests sit on top of. Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
appliedis drain-owned, and that KeepResident is right by accident