feat(engine): PR-6.75 — C0-full+C1 read/write conflict profiler; sound CR 603.3b same-event trigger ordering - #5072
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
Holding this head for required review evidence before any approval/enqueue decision. This is a large hot-path engine change ( |
|
🤖 AI text below 🤖 @matthewevans Evidence added for this head — the PR body now carries the required template sections:
Current-head checks have also populated since your comment: all required checks green, including |
|
Thanks for the follow-up. I rechecked the current packet for head The remaining machine-visible gaps are:
For this large hot-path engine trigger-ordering change, please update the PR body so the required sections and implementation-method checklist are unambiguous on the current head. I’ll hold this until that proof gate clears. |
|
🤖 AI text below 🤖 @matthewevans Body restructured to the |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Same-event member-bound triggers can still auto-order. Evidence: crates/engine/src/game/ability_rw.rs:960 uses the same-event fast path when source_independent() is true, while source_independent() at crates/engine/src/game/ability_rw.rs:745 ignores reads_member_bound; member-bound targets are explicitly flagged at crates/engine/src/game/ability_rw.rs:1547 from carriers such as TrackedSet and ExiledBySource listed at crates/engine/src/game/ability_rw.rs:2254. Why it matters: same-event triggers whose resolution reads per-source storage can be treated as one source-independent function and skip the ordering choice even though the order can change the result. Suggested fix: make member-bound profiles refuse the same-event source_independent fast path and add a same-event tracked/exiled-by-source discriminator.
[HIGH] Non-phase delayed triggers bypass the CR 603.3b ordering gate. Evidence: crates/engine/src/game/triggers.rs:5242 APNAP-sorts delayed triggers, then crates/engine/src/game/triggers.rs:5256 iterates and crates/engine/src/game/triggers.rs:5275 dispatches each trigger directly; the phase-delayed path routes its pending batch through begin_trigger_ordering at crates/engine/src/game/triggers.rs:5547. Why it matters: simultaneous same-controller delayed triggers can reach the stack without the required ordering choice. Suggested fix: convert the non-phase delayed to_fire batch into PendingTriggerContexts and run it through begin_trigger_ordering before dispatching.
…inator (CR 603.3b) Close a hole in the same-event ordering-soundness gate (PR phase-rs#5072 review, HIGH-1): profiles_conflict's same-event fast path auto-ordered any source_independent group, but a group of DISTINCT sources whose identical resolution reads per-source bound storage (CR 603.10a look-back -- TrackedSet / ExiledBySource / ChosenCard, via member_bound_target_filter) is NOT one shared f(state): member A reads A's storage, member B reads B's, so the identical-function commutation proof breaks and the order can be observable. Same-event order-independence is decided solely by !same_event_conflict (no c2 backstop), so this auto-ordered genuinely order-dependent groups -- e.g. two Mimic Vats racing to imprint one shared dying creature. Fix (mirrors the batch path's !reads_member_bound conjunct at same-event depth): - exclude member-bound from the source_independent fast-path disjunct; - add the fail-closed discriminator `if s.same_event && p.reads_member_bound { true }`. all_same_source stays auto-ordered (one shared source => one shared storage => f_A=f_B). The batch path is byte-inert (both edits are s.same_event-guarded). Not latent: the full-DB sweep flips 48 same-event cards auto->prompt (all over-prompt direction -- the base engine auto-ordered every same-event group unconditionally, so these can never be under-prompts). 6 GENUINE order-dependent (mimic vat, mirror of life trapping, moonring mirror, duplicity, world queller, blood tyrant) + 42 conservative safe over-prompts. The sweep documents them as a predicate-keyed CLASS (reads_member_bound, corpus-churn-robust) with the 6 genuine enumerated (SAME_EVENT_MEMBER_BOUND_GENUINE) and the 42 conservative derived + emitted as evidence -- no 48-name allowlist. Sweep stays green (unexplained=0); t1_source_indep measured 2871->2830 (the source-independent slice now prompts), floor unchanged. Assisted-by: ClaudeCode:claude-opus-4.8
17c8aa9 to
7d34c01
Compare
…rigger_ordering (CR 603.3b) Close the HIGH-2 finding (PR phase-rs#5072 review): check_delayed_triggers (CR 603.7) APNAP-sorted its firing batch then DIRECTLY dispatched each trigger, bypassing begin_trigger_ordering. So two+ simultaneous same-controller order-dependent non-phase delayed triggered abilities (e.g. two "when a creature dies" deals-damage / put-counter triggers off one death) reached the stack in fixed order with no CR 603.3b ordering choice. This dispatch tail was byte-identical base->HEAD (pre-existing); the PR made begin_trigger_ordering the sound ordering authority but never wired this path. The phase-delayed path (process_collected_triggers_with_delayed_phase_events, phase-rs#5048) is the template. Fix (maintainer's shape, minimal): - check_delayed_triggers: convert the to_fire batch into PendingTriggerContexts (reusing delayed_trigger_to_context), APNAP-sort, route through begin_trigger_ordering -- PromptForChoice sets waiting_for, NoChoiceNeeded dispatches via dispatch_deferred_triggers_in_order. The bespoke Paused / DroppedTargetUnresolved / DroppedNoLegalMode / ResolvedInline arms are deleted: the shared dispatcher applies every Delayed-origin disposition unchanged (Good King Mog DroppedTargetUnresolved->Pushed, Breeches reflexive pause, inline mana). - engine_priority: surface an OrderTriggers prompt set by check_delayed_triggers before check_state_triggers / the Priority fallthrough clobbers it (scoped to OrderTriggers, so the Breeches pending_trigger pause is untouched). Pure CR 603.3b tightening: singletons and provably-commuting identical no-input groups still auto-order; only genuinely order-dependent or distinct-input same-controller batches now prompt. One integration test (daretti_emblem_simultaneous_death) asserted the old direct-dispatch contract for two distinct-target simultaneous returns; it is updated to a positive OrderTriggers pin (its outcome assertions are unchanged and still pass). The impl-plan's caller census under-counted that test: it grepped crates/engine/src (inline #[cfg(test)] modules) but not crates/engine/tests/ (integration dir). A full source re-census plus the exactly-one-failure signal confirmed Daretti is the only newly-prompting caller (encore's 2x Sacrifice{SelfRef} is order-independent and still auto-orders; every other caller is a singleton). Follow-up (latent, no card/test exercises it): the check_delayed_triggers call in engine_resolution_choices (ChooseBranch deferred-ETB replay) could surface an OrderTriggers prompt that drain_pending_continuation might overwrite; strictly no worse than pre-HIGH-2 (which never ordered anywhere in check_delayed_triggers). phase-rs#4809 (Braids / Spinal Embrace) is a distinct phase-path seam, not this bug: "at the beginning of the next end step" is phase-classified and already merged + ordered by the phase path; this fix neither closes nor regresses it. Assisted-by: ClaudeCode:claude-opus-4.8
|
🤖 AI text below 🤖 @matthewevans Both [HIGH] findings are fixed, and the first one was a better catch than "plausible hole": the sweep measured it as live, not latent — 48 same-event cards were silently auto-ordering, and 6 of them are genuinely order-observable (real under-prompts): Mimic Vat + Mirror of Life Trapping (imprint-contention for the one shared triggering creature), Moonring Mirror + Duplicity (hand-swap re-capture, A feeds B), World Queller (shared-pool consumption under divergent chosen types), Blood Tyrant (self-write × CR 704.5a/800.4a elimination edge, caught incidentally). Thank you — the review directly removed reachable rules-wrong behavior. HIGH-1 ( HIGH-2 ( Related issue triage: #4809 (Braids/Spinal) is a DISTINCT seam — Rebase + gates: series rebased onto 🤖 Generated with Claude Code |
matthewevans
left a comment
There was a problem hiding this comment.
Findings:
- [HIGH] The new same-event gate is not actually fail-closed for all order-sensitive groups.
ability_rw.rsdocuments thatreads_event_liveis never consulted on the same-event path, and gives a same-event write-then-read example that remains order-observable but still auto-orders (crates/engine/src/game/ability_rw.rs:33). That contradicts both the PR claim of a sound CR 603.3b same-event gate and thegroup_is_order_independentcontract that says it "can never auto-order order-sensitive triggers" (crates/engine/src/game/triggers.rs:3542). The code path confirms the gap:profiles_conflictonly includesreads_event_livein the non-same-event/batch checks (crates/engine/src/game/ability_rw.rs:991,crates/engine/src/game/ability_rw.rs:1005,crates/engine/src/game/ability_rw.rs:1032), while same-event ordering is decided by!same_event_conflict(crates/engine/src/game/triggers.rs:3465). Please either close this same-event event-object read/write feed in the classifier with a discriminating test, or narrow the implementation/claims so we are not landing a known rules-wrong same-event auto-order path as a sound CR 603.3b gate.
…inator (CR 603.3b) Close a hole in the same-event ordering-soundness gate (PR phase-rs#5072 review, HIGH-1): profiles_conflict's same-event fast path auto-ordered any source_independent group, but a group of DISTINCT sources whose identical resolution reads per-source bound storage (CR 603.10a look-back -- TrackedSet / ExiledBySource / ChosenCard, via member_bound_target_filter) is NOT one shared f(state): member A reads A's storage, member B reads B's, so the identical-function commutation proof breaks and the order can be observable. Same-event order-independence is decided solely by !same_event_conflict (no c2 backstop), so this auto-ordered genuinely order-dependent groups -- e.g. two Mimic Vats racing to imprint one shared dying creature. Fix (mirrors the batch path's !reads_member_bound conjunct at same-event depth): - exclude member-bound from the source_independent fast-path disjunct; - add the fail-closed discriminator `if s.same_event && p.reads_member_bound { true }`. all_same_source stays auto-ordered (one shared source => one shared storage => f_A=f_B). The batch path is byte-inert (both edits are s.same_event-guarded). Not latent: the full-DB sweep flips 48 same-event cards auto->prompt (all over-prompt direction -- the base engine auto-ordered every same-event group unconditionally, so these can never be under-prompts). 6 GENUINE order-dependent (mimic vat, mirror of life trapping, moonring mirror, duplicity, world queller, blood tyrant) + 42 conservative safe over-prompts. The sweep documents them as a predicate-keyed CLASS (reads_member_bound, corpus-churn-robust) with the 6 genuine enumerated (SAME_EVENT_MEMBER_BOUND_GENUINE) and the 42 conservative derived + emitted as evidence -- no 48-name allowlist. Sweep stays green (unexplained=0); t1_source_indep measured 2871->2830 (the source-independent slice now prompts), floor unchanged. Assisted-by: ClaudeCode:claude-opus-4.8
…rigger_ordering (CR 603.3b) Close the HIGH-2 finding (PR phase-rs#5072 review): check_delayed_triggers (CR 603.7) APNAP-sorted its firing batch then DIRECTLY dispatched each trigger, bypassing begin_trigger_ordering. So two+ simultaneous same-controller order-dependent non-phase delayed triggered abilities (e.g. two "when a creature dies" deals-damage / put-counter triggers off one death) reached the stack in fixed order with no CR 603.3b ordering choice. This dispatch tail was byte-identical base->HEAD (pre-existing); the PR made begin_trigger_ordering the sound ordering authority but never wired this path. The phase-delayed path (process_collected_triggers_with_delayed_phase_events, phase-rs#5048) is the template. Fix (maintainer's shape, minimal): - check_delayed_triggers: convert the to_fire batch into PendingTriggerContexts (reusing delayed_trigger_to_context), APNAP-sort, route through begin_trigger_ordering -- PromptForChoice sets waiting_for, NoChoiceNeeded dispatches via dispatch_deferred_triggers_in_order. The bespoke Paused / DroppedTargetUnresolved / DroppedNoLegalMode / ResolvedInline arms are deleted: the shared dispatcher applies every Delayed-origin disposition unchanged (Good King Mog DroppedTargetUnresolved->Pushed, Breeches reflexive pause, inline mana). - engine_priority: surface an OrderTriggers prompt set by check_delayed_triggers before check_state_triggers / the Priority fallthrough clobbers it (scoped to OrderTriggers, so the Breeches pending_trigger pause is untouched). Pure CR 603.3b tightening: singletons and provably-commuting identical no-input groups still auto-order; only genuinely order-dependent or distinct-input same-controller batches now prompt. One integration test (daretti_emblem_simultaneous_death) asserted the old direct-dispatch contract for two distinct-target simultaneous returns; it is updated to a positive OrderTriggers pin (its outcome assertions are unchanged and still pass). The impl-plan's caller census under-counted that test: it grepped crates/engine/src (inline #[cfg(test)] modules) but not crates/engine/tests/ (integration dir). A full source re-census plus the exactly-one-failure signal confirmed Daretti is the only newly-prompting caller (encore's 2x Sacrifice{SelfRef} is order-independent and still auto-orders; every other caller is a singleton). Follow-up (latent, no card/test exercises it): the check_delayed_triggers call in engine_resolution_choices (ChooseBranch deferred-ETB replay) could surface an OrderTriggers prompt that drain_pending_continuation might overwrite; strictly no worse than pre-HIGH-2 (which never ordered anywhere in check_delayed_triggers). phase-rs#4809 (Braids / Spinal Embrace) is a distinct phase-path seam, not this bug: "at the beginning of the next end step" is phase-classified and already merged + ordered by the phase path; this fix neither closes nor regresses it. Assisted-by: ClaudeCode:claude-opus-4.8
7d34c01 to
6dc10b9
Compare
…inator (CR 603.3b) Maintainer review (phase-rs#5072): the same-event CR 603.3b gate was not fail-closed for the event-object read/write feed. `reads_event_live` was consulted only on the batch path (profiles_conflict all_same_source fast path + freeze-invalidation row, both !same_event-guarded), never in same-event feed analysis — so a same-event group that WRITES the shared triggering object and READS its live characteristic ("put a +1/+1 counter on it, then transform ~ if that creature's power >= 6" x2) auto-ordered, contradicting the group_is_order_independent contract ("can never auto-order order-sensitive triggers"). Root cause: `read_object_scope` maps EventSource/EventTarget reads to `reads_event_live`, which sets only a bool and records no KindSet, so the feeds() kind x kind matrix is structurally blind to a `writes_event_object` mutation feeding an event-object live read (CR 608.2h: the read uses the object's current information). On the same-event path all members share ONE live event object, so a member's write is observed by a sibling's live read => order-observable. Fix (close the feed in the classifier), mirroring the R3 HIGH-1 member-bound discriminator, DERIVED (not copied) from the batch-T1 event-object conjunct: - New `reads_and_writes_event_object() = reads_event_live && writes_event_object .any()`. The batch guard is a disjunction-of-negations (refusing the batch fast path only DEFERS to the feed rows); the same-event discriminator returns a PROMPT, so it fires only on a real feed = BOTH endpoints, a CONJUNCTION. - profiles_conflict: the same-event T1 fast-path source_independent branch gains `&& !(s.event_object_present && reads_and_writes_event_object())`, and a new discriminator `if s.same_event && s.event_object_present && reads_and_writes_event_object() { return true }` after the member-bound one. - Gated on `event_object_present` (mirrors effective_external): a write to a non-present event object no-ops (targeting.rs:951), so a Phase-mode trigger stays auto (no live object => no feed). A genuine feed always has a live event object, so the conjunct can only drop vacuous prompts (zero under-prompt risk) and converges with the batch path's net behavior. all_same_source stays auto (identical f over one shared event object, deterministic accumulation). Both edits are s.same_event-guarded => the batch path is byte-inert. The same-event arm's decision_old is always auto, so every flip is auto->prompt (over-prompt, never under-prompt). CR 603.3b + CR 608.2h (grep-verified). Full-DB parity sweep: +13 same-event flips, ALL conservative (0 genuine), predicate-keyed as `se_event_object_class` (membership DERIVED + emitted as the handoff evidence artifact, corpus-churn-robust). GOVERNING THEOREM (why R4 has no genuine member, unlike R3): the discriminator groups IDENTICAL siblings, and every R4 write targets the SHARED event object (identical f o f is relabel- invariant) or each copy's disjoint self => order-invariant final state. R3's genuine members wrote to PER-SOURCE storage (imprint pile A != pile B), an asymmetric destination that IS order-divergent. The class is a pure fail-closed safety over-prompt: sound but never necessary. `SAME_EVENT_EVENT_OBJECT_GENUINE` ships EMPTY (0 genuine, exact-set asserted). Class disjoint from `se_member_bound_class` by construction (asserted). Full per-card both-orders evidence in the review artifact. Module doc rewritten: the direct event-object feed is now CLOSED at both depths (same-event discriminator; batch freeze/T1). Two residuals REMAIN, both SYMMETRIC across depths: the source-actor granted-state channel and a board-wide external-write x event-live-read channel (feeds() blind, batch T1 keys on writes_event_object too). The gate is sound modulo these two profile-invisible symmetric residuals; the group_is_order_independent contract now records exactly that. Discriminating unit test `r4_same_event_event_object_feed_conjunct` (NEG prompts; read-only / write-only / event_object_present / all_same_source guards auto). Revert-to-red measured: disabling the discriminator turns the NEG red. Assisted-by: ClaudeCode:claude-opus-4.8
|
🤖 AI text below 🤖 @matthewevans Round-4 finding accepted and fixed via option 1 — the feed is closed ( The fix: a new shared predicate Measured impact: +13 same-event flips, all over-prompt direction, and — honestly — 0 genuine. Every one of the 13 writes to the shared event object, and k identical functions on one shared object compose to the same final state under every permutation; R3's genuine members existed because their writes diverged into per-source piles. So Claims-consistency: the module doc now records this feed as CLOSED at both depths and enumerates the two residuals that remain — source-actor granted-state, and board-wide Rebase + gates: upstream moved during the fix (#5080 parser, #5088 World-rule SBA, #5089 data), so the full series was rebased onto 🤖 Generated with Claude Code |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Please add a production-path test for the R4 event-object read/write discriminator. Evidence: triggers.rs builds event_object_present from the real pending trigger event and feeds that structure into profiles_conflict through group_is_order_independent; the current R4 coverage I can find is the sweep classifier plus lower-level profiles_conflict coverage, while the explicit group_is_order_independent regression around line 1884 covers the member-bound case, not the event-object feed. Why it matters: this is the exact hot-path threading that regressed in the prior review loop; a future mis-threading/revert in the caller could auto-order the R4 shape while the synthetic predicate test still passes. Suggested fix: add a focused triggers_ordering_parity_tests.rs test that calls group_is_order_independent with distinct sources on one shared event object, an EventSource/EventTarget live read plus a TriggeringSource event-object write, and guard cases for read-only, write-only/no-event, and same-source so the test proves the discriminator rather than a blanket prompt.
…ler (CR 603.3b)
The D-profile classifier for sound trigger-ordering (CR 603.3b): a kind- and
scope-aware read/write conflict profile over one ability, for the legacy
UNGATED ordering paths (C0-full + C1). No consumer yet — the triggers.rs
rewiring lands in commit 2, so this commit is zero behavior change and
loop_detection is unaffected.
NEW crates/engine/src/game/ability_rw.rs:
- StateKind/KindSet/Census/SourceCensus/RwProfile (+ source_independent) /
GroupStructure / feeds / profiles_conflict / ability_rw_profile /
trigger_condition_rw_profile. Exhaustive wildcard-free walk over
Effect/QuantityRef/QuantityExpr/*Condition/TargetFilter/ObjectScope/… mirroring
ability_scan's traversal-closure discipline (M3 binding mandate: precise arms
bind all payload fields; { .. } only on maximal-conservative RHS).
- The §1.2 commutation formula: CR 603.10a + CR 400.7 LKI freeze, CR 603.4
intervening-if, CR 603.5 resolution-time-choice exclusions, CR 603.7 deferred
bodies; the census-overlap membership feed row; the ParentTarget chain-root +
event-object disjointness anaphoric rules (resolver-pinned); D5
legacy_batch_prompt retained-prompt classification. Sound modulo the documented
source-actor residual (CR 702.15 lifelink / CR 702.2 deathtouch / CR 800.4a
player-loss cascade), inherited unchanged from the shipped short-circuit.
- 16 N-E unit pairings (§5.4) — each conflict/clean pair revert-fail-proven.
Analysis-internal types only; no gated-enum variant added (engine-inventory clean).
triggers.rs and ability_scan.rs byte-untouched (axis-3 immutability invariant, D2).
Assisted-by: ClaudeCode:claude-opus-4.8
…gate (CR 603.3b) Retire the fail-open 12-string serde-walk allowlist (C0-full) and install a sound read/write conflict gate at the same-event short-circuit (C1) — the latent CR 603.3b bug where identical order-dependent siblings were auto-ordered instead of prompting the controller. Both live on the legacy UNGATED paths (the same-event/batch short-circuit that always ran and always auto-ordered); C1 is monotone-safe (OLD always auto-ordered, so C1 can only add mandatory ordering prompts, never remove one). The game-changing distinct-event term (C2) stays gated on loop_detection. - triggers.rs: rewire trigger_events_match_for_ordering / group_is_order_independent onto the ability_rw conflict profiler. C1 same-event + C0-full ZoneChanged departure batch inherited unconditionally; C2 distinct-event auto-order stays loop_detection-gated (default OFF byte-preserves distinct-event gameplay). - ability_rw.rs: Class-C classifier refinements on the profiler — zone-aware SetMembership census (CR 400.1, Tombstone Stairwell battlefield-write vs graveyard-read), external counter object-census (CR 122.1, Earthbender Ascension), and delver/abattoir/deadeye read-kind corrections; ParentTargetSlot classified as a non-D5 event ref (restores the exactly-12-tag legacy_batch_prompt property). - triggers_ordering_parity_tests.rs: whole-corpus §5.2 allowlist-parity sweep (frozen 12-string oracle vs the new gate — 0 unexplained modulo the 7 proven category-(1) rows) plus the N-A..N-F soundness discriminators and the class-A reachability guards (legendary CR 704.5j, per-source DamageDone via valid_source, CR 603.4 condition self-exclusion). - ability_scan.rs: dual-walk maintenance doc paragraph only (D2 — zero match-arm / Axes literal change). Assisted-by: ClaudeCode:claude-opus-4.8
…or (CR 603.10a) The batch-path retained-prompt flag (legacy_batch_prompt, D5) was set only at scattered typed leaf sites reached by the read/write classification walk, so a legacy event-context ref sitting in an effect target/count position — or a deeper subtree the walk doesn't descend (TypedFilter.controller, FilterProp interior, static-ability affected filter, granted-trigger body) — left the flag false and auto-ordered a departure batch the shipped engine prompted (CR 603.3b fail-open; 50 cards in the full-DB corpus). Replace the leaf-hooking with a dedicated typed recursive visitor `contains_legacy_event_ref(&ResolvedAbility)` that visits every structural position a legacy ref can occupy — all TargetFilters (incl. nested / chain-root), QuantityExpr/QuantityRef counts, ObjectScope/PlayerFilter/ControllerRef, across all Effect variants, sub-ability/modal chains, and the trigger condition. Every match is exhaustive with no wildcard, so a future variant fails to compile until classified. legacy_batch_prompt is set authoritatively from the visitor, decoupled from walk descent. Typed, not a serde walk — this runs on the trigger-ordering hot path (the regression phase-rs#4912 exists to prevent). Proof (typed == frozen serde oracle, zero disagreement): - Mechanical tag x position matrix test: 12 frozen tags across 5 target / 3 quantity / scope / player / controller positions, driven through the production profiling entry points, with negative controls. - Full-DB parity sweep: BATCH prompt->auto diffs 50 -> 0, no over-flagging (total unexplained is exactly the out-of-scope 15 batch auto->prompt + 49 same-event), retained_prompt +50. The frozen serde oracle stays test-side as the reference. Assisted-by: ClaudeCode:claude-opus-4.8
…603.3b)
Ten pure read/write-classification refinements to the trigger-ordering
conflict profiler, each turning a provably-commuting same-event / departure-
batch group from a needless PROMPT into an AUTO-order (fail-closed: a missed
refinement over-prompts, never under-prompts).
- L1 ZoneCardCount{zone,card_types} → extract census + read-zone (was Any/Any)
- L2 zone-change/sacrifice/counter journals keyed to destination zone / frozen
look-back; self token-copy records its battlefield creation zone
- L3 TargetMatchesFilter{use_lki:true} → frozen LKI read (CR 603.10a)
- L6 AttachedTo valid_card provably excludes every source (CR 701.3d)
- L7 ObjectsShareQuality{LastRevealed} operand is a per-resolution local
- L9 CopySpell{TriggeringSource|ParentTarget} reads the original by id;
ChangeTargets is a StackShape write, not the conservative fallback (CR 115.7)
- L12 Suspect/Unsuspect is an idempotent designation with no observable write
- L13 RemoveFromCombat/SkipNextStep/SkipNextTurn → new TurnStructure kind
- L14 ExtraTurn/AdditionalPhase → TurnStructure (was the Other catch-all)
- voltstorm: reflexive-modal mode_abilities descend as a union, not conservative
New StateKind::TurnStructure (CR 500/505.6/614.10) — a sequencing kind that only
self-conflicts, replacing over-broad Other for turn/combat-structure writes.
Full-DB sweep (bb03392 corpus): 64 → 27 unexplained over-prompts cleared.
12 discriminating unit tests (POS commute + adjacent NEG), each with revert-fail
evidence. paroxysm / flamewake phoenix left as documented conservative-safe
residual (context-free unclassifiable). L10/L11 (controller/player scope) split
out to the same_controller commit; L4/L5/skyfisher to commit 5.
Assisted-by: ClaudeCode:claude-opus-4.8
…(CR 603.3b) Adds a controller-uniform AND owner-aligned group fact (GroupStructure.same_controller, live-computed at the group_is_order_independent chokepoint) plus gate-scoped PlayerSpan axes on RwProfile, so same-controller sibling triggers whose reads/writes are player/controller-disjoint auto-order instead of over-prompting. CR 603.3b + CR 109.5/102.2 (you vs opponents disjoint at N players) + CR 400.3/110.2/108.3 (owner-keyed self-write destinations are resolvable only under owner alignment). Clears 3 over-prompts (full-DB sweep 27 -> 24): defense of the heart, rekindled flame, brink of madness. Osseous sticktwister stays a documented-conservative prompt -- its your-graveyard read against an opponents-sacrifice write is order-observable when you own an opponent-controlled permanent (CR 701.21a: sacrifice moves to the owner's graveyard), so clearing it would be an under-prompt. Fail-closed by construction: same_controller=false makes profiles_conflict byte-identical to before. 11 discriminating tests (S1-S7) drive the production authority group_is_order_independent, including a 3-player multiplayer proof and revert-fail evidence for the gate and the owner-alignment conjunct. Assisted-by: ClaudeCode:claude-opus-4.8
Refactors the same_controller group fact into ControllerUniformity
{Mixed, Uniform, UniformAligned} and adds a Batch-T1 clause: in a
controller-uniform co-departure batch of normalized-identical members, a
resolution that consults neither its source binding, per-member bound storage
(new reads_member_bound fact), its firing event, nor event-object writes is
one function f(state, controller) shared by every member, so any resolution
order composes f with f -- CR 603.3b ordering is unobservable. Uniformity, not
controller-independence, is the sound bar: production partitions ordering
groups by controller (begin_trigger_ordering), so every non-team group is
uniform by construction; only CR 805.7 team pools mix, and those compute
uniformity live and fail closed to Mixed. A mixed-controller batch is
genuinely order-observable -- a discard's cause-source controller
(EventSourceControlledBy) flips with resolution order.
Clears 5 batch over-prompts (full-DB sweep 24 -> 19): emrakul the world anew,
mindslicer, ruin grinder, slithermuse, yukora the prisoner. Day of the Dragons
stays prompting -- its per-source TrackedSet return feeds the other member's
re-sacrifice, so it is genuinely order-dependent (a correct prompt, not an
over-prompt). Skyfisher Spider stays prompting -- owner-misaligned self-exile
makes its graveyard read order-observable.
Fail-closed by construction: ControllerUniformity::Mixed consults no
refinement; UniformAligned reproduces the landed same_controller==true
decisions byte-for-byte (27->24 preserved). CR 603.10a per-member-bound
referents are flagged fail-closed at every TargetFilter, quantity, and
scope/anchor position. 12 discriminating tests (B-1..B-7 plus an executable
mixed-controller Dodecapod negative) drive the production authority
group_is_order_independent, with revert-fail evidence for every T1 conjunct.
Assisted-by: ClaudeCode:claude-opus-4.8
…mpts + DotD genuine (CR 603.3b) Makes the full-DB trigger-ordering parity sweep GREEN (was panicking at 19 unexplained). Adds a self-documenting DOCUMENTED_OVER_PROMPT allowlist (18 conservative over-prompts, each with its class + reason) consulted behind a direction gate (decision_old && !decision_new — an under-prompt is never suppressible), a separate BATCH_GENUINE_ROWS set for Day of the Dragons (the one genuinely order-dependent batch, CR 603.3b), and full-DB completeness asserts (a stale or renamed entry fails). 19 = 18 documented-conservative + 1 genuine (Day of the Dragons). The conservative 18: 12 L8-held monotone/self-limiting (CR 603.4); osseous sticktwister + skyfisher spider (owner-misalignment / osseous-class: auto-safe when owner-aligned, correctly prompted when misaligned via CR 701.21a); paroxysm + flamewake phoenix (context-free-unclassifiable); deep-sea kraken + ichorplate golem (parse-blocked commutes). Floor re-tune: batch_self_srcread REMOVED — its cell is empty in-corpus; the old >=40 population was an artifact of the D5 fail-open hole closed in commit N-G, now counted by retained_prompt, so a >=0 assert would be decorative. The four surviving floors are re-tuned to full-DB measured value minus 5% (batch_obs >=469, retained_prompt >=271, t1_source_indep >=2727, hadcounters_batch_self >=1) and each is mutation-proven non-vacuous (probe matrix M1-M9). Test-only: zero engine-logic change. Assisted-by: ClaudeCode:claude-opus-4.8
…inator (CR 603.3b) Close a hole in the same-event ordering-soundness gate (PR phase-rs#5072 review, HIGH-1): profiles_conflict's same-event fast path auto-ordered any source_independent group, but a group of DISTINCT sources whose identical resolution reads per-source bound storage (CR 603.10a look-back -- TrackedSet / ExiledBySource / ChosenCard, via member_bound_target_filter) is NOT one shared f(state): member A reads A's storage, member B reads B's, so the identical-function commutation proof breaks and the order can be observable. Same-event order-independence is decided solely by !same_event_conflict (no c2 backstop), so this auto-ordered genuinely order-dependent groups -- e.g. two Mimic Vats racing to imprint one shared dying creature. Fix (mirrors the batch path's !reads_member_bound conjunct at same-event depth): - exclude member-bound from the source_independent fast-path disjunct; - add the fail-closed discriminator `if s.same_event && p.reads_member_bound { true }`. all_same_source stays auto-ordered (one shared source => one shared storage => f_A=f_B). The batch path is byte-inert (both edits are s.same_event-guarded). Not latent: the full-DB sweep flips 48 same-event cards auto->prompt (all over-prompt direction -- the base engine auto-ordered every same-event group unconditionally, so these can never be under-prompts). 6 GENUINE order-dependent (mimic vat, mirror of life trapping, moonring mirror, duplicity, world queller, blood tyrant) + 42 conservative safe over-prompts. The sweep documents them as a predicate-keyed CLASS (reads_member_bound, corpus-churn-robust) with the 6 genuine enumerated (SAME_EVENT_MEMBER_BOUND_GENUINE) and the 42 conservative derived + emitted as evidence -- no 48-name allowlist. Sweep stays green (unexplained=0); t1_source_indep measured 2871->2830 (the source-independent slice now prompts), floor unchanged. Assisted-by: ClaudeCode:claude-opus-4.8
…rigger_ordering (CR 603.3b) Close the HIGH-2 finding (PR phase-rs#5072 review): check_delayed_triggers (CR 603.7) APNAP-sorted its firing batch then DIRECTLY dispatched each trigger, bypassing begin_trigger_ordering. So two+ simultaneous same-controller order-dependent non-phase delayed triggered abilities (e.g. two "when a creature dies" deals-damage / put-counter triggers off one death) reached the stack in fixed order with no CR 603.3b ordering choice. This dispatch tail was byte-identical base->HEAD (pre-existing); the PR made begin_trigger_ordering the sound ordering authority but never wired this path. The phase-delayed path (process_collected_triggers_with_delayed_phase_events, phase-rs#5048) is the template. Fix (maintainer's shape, minimal): - check_delayed_triggers: convert the to_fire batch into PendingTriggerContexts (reusing delayed_trigger_to_context), APNAP-sort, route through begin_trigger_ordering -- PromptForChoice sets waiting_for, NoChoiceNeeded dispatches via dispatch_deferred_triggers_in_order. The bespoke Paused / DroppedTargetUnresolved / DroppedNoLegalMode / ResolvedInline arms are deleted: the shared dispatcher applies every Delayed-origin disposition unchanged (Good King Mog DroppedTargetUnresolved->Pushed, Breeches reflexive pause, inline mana). - engine_priority: surface an OrderTriggers prompt set by check_delayed_triggers before check_state_triggers / the Priority fallthrough clobbers it (scoped to OrderTriggers, so the Breeches pending_trigger pause is untouched). Pure CR 603.3b tightening: singletons and provably-commuting identical no-input groups still auto-order; only genuinely order-dependent or distinct-input same-controller batches now prompt. One integration test (daretti_emblem_simultaneous_death) asserted the old direct-dispatch contract for two distinct-target simultaneous returns; it is updated to a positive OrderTriggers pin (its outcome assertions are unchanged and still pass). The impl-plan's caller census under-counted that test: it grepped crates/engine/src (inline #[cfg(test)] modules) but not crates/engine/tests/ (integration dir). A full source re-census plus the exactly-one-failure signal confirmed Daretti is the only newly-prompting caller (encore's 2x Sacrifice{SelfRef} is order-independent and still auto-orders; every other caller is a singleton). Follow-up (latent, no card/test exercises it): the check_delayed_triggers call in engine_resolution_choices (ChooseBranch deferred-ETB replay) could surface an OrderTriggers prompt that drain_pending_continuation might overwrite; strictly no worse than pre-HIGH-2 (which never ordered anywhere in check_delayed_triggers). phase-rs#4809 (Braids / Spinal Embrace) is a distinct phase-path seam, not this bug: "at the beginning of the next end step" is phase-classified and already merged + ordered by the phase path; this fix neither closes nor regresses it. Assisted-by: ClaudeCode:claude-opus-4.8
…ity_rw dual-walk (CR 603.3b) Rebasing the PR-6.75 series onto v0.15.0 (upstream/main 1badc19) surfaced 6 new upstream enum variants that the wildcard-free ability_rw read/write dual-walk must classify (E0004 non-exhaustive match). Each is classified per its rw-axis, with grep-verified CR annotations: FilterProp::InTrackedSet member-bound (chain tracked-set membership, property form of TargetFilter::TrackedSet) CR 603.10a Effect::EachSourceDealsDamage board-membership read + damage/life write CR 120.1 / 120.3a / 608.2c Effect::ChooseCounterKind ObjectCounters read + member-bound (persists per-source chosen counter kind) CR 122 / 603.10a Effect::PutChosenCounter ObjectCounters write + member-bound (consumes per-source chosen counter kind) CR 122.1 / 122.6 / 603.10a Effect::CreatePlaneswalkReplacement deferred body (descend reads, drop writes) CR 614.1a / 611.2c / 603.7 Effect::ChaosEnsues external write (planar chaos trigger) CR 311.7 / 901.9b WHY a standalone commit rather than folded into the owning profiler commits (c1 rw_effect / c3 legacy_* / c6 member_bound): three arms depend on engine features introduced LATER in this very series. CreatePlaneswalkReplacement uses `pscope` / `chain_move_owner` / the 4-arg `rw_effect` signature (born in the same_controller commit); ChooseCounterKind / PutChosenCounter set `p.reads_member_bound` (the field is born in the batch-hard commit). Since rw_effect's match must be exhaustive from the profiler commit onward, a per-commit-compiles fold would require deliberately- wrong evolving stub arms across three commits -- worse than an honest single adaptation. Per-commit-compiles for c1-c8 was in any case already forfeited by the rebase itself (those commits are non-exhaustive against the new base's variants by construction); the sound bisect build points are the base (1badc19) and this HEAD. (The pre-PR rebase folded its 15 new-variant arms into owning commits; this rebase could not, for the dependency reason above -- measured necessity, not a convention lapse.) Assisted-by: ClaudeCode:claude-opus-4.8
…inator (CR 603.3b) Maintainer review (phase-rs#5072): the same-event CR 603.3b gate was not fail-closed for the event-object read/write feed. `reads_event_live` was consulted only on the batch path (profiles_conflict all_same_source fast path + freeze-invalidation row, both !same_event-guarded), never in same-event feed analysis — so a same-event group that WRITES the shared triggering object and READS its live characteristic ("put a +1/+1 counter on it, then transform ~ if that creature's power >= 6" x2) auto-ordered, contradicting the group_is_order_independent contract ("can never auto-order order-sensitive triggers"). Root cause: `read_object_scope` maps EventSource/EventTarget reads to `reads_event_live`, which sets only a bool and records no KindSet, so the feeds() kind x kind matrix is structurally blind to a `writes_event_object` mutation feeding an event-object live read (CR 608.2h: the read uses the object's current information). On the same-event path all members share ONE live event object, so a member's write is observed by a sibling's live read => order-observable. Fix (close the feed in the classifier), mirroring the R3 HIGH-1 member-bound discriminator, DERIVED (not copied) from the batch-T1 event-object conjunct: - New `reads_and_writes_event_object() = reads_event_live && writes_event_object .any()`. The batch guard is a disjunction-of-negations (refusing the batch fast path only DEFERS to the feed rows); the same-event discriminator returns a PROMPT, so it fires only on a real feed = BOTH endpoints, a CONJUNCTION. - profiles_conflict: the same-event T1 fast-path source_independent branch gains `&& !(s.event_object_present && reads_and_writes_event_object())`, and a new discriminator `if s.same_event && s.event_object_present && reads_and_writes_event_object() { return true }` after the member-bound one. - Gated on `event_object_present` (mirrors effective_external): a write to a non-present event object no-ops (targeting.rs:951), so a Phase-mode trigger stays auto (no live object => no feed). A genuine feed always has a live event object, so the conjunct can only drop vacuous prompts (zero under-prompt risk) and converges with the batch path's net behavior. all_same_source stays auto (identical f over one shared event object, deterministic accumulation). Both edits are s.same_event-guarded => the batch path is byte-inert. The same-event arm's decision_old is always auto, so every flip is auto->prompt (over-prompt, never under-prompt). CR 603.3b + CR 608.2h (grep-verified). Full-DB parity sweep: +13 same-event flips, ALL conservative (0 genuine), predicate-keyed as `se_event_object_class` (membership DERIVED + emitted as the handoff evidence artifact, corpus-churn-robust). GOVERNING THEOREM (why R4 has no genuine member, unlike R3): the discriminator groups IDENTICAL siblings, and every R4 write targets the SHARED event object (identical f o f is relabel- invariant) or each copy's disjoint self => order-invariant final state. R3's genuine members wrote to PER-SOURCE storage (imprint pile A != pile B), an asymmetric destination that IS order-divergent. The class is a pure fail-closed safety over-prompt: sound but never necessary. `SAME_EVENT_EVENT_OBJECT_GENUINE` ships EMPTY (0 genuine, exact-set asserted). Class disjoint from `se_member_bound_class` by construction (asserted). Full per-card both-orders evidence in the review artifact. Module doc rewritten: the direct event-object feed is now CLOSED at both depths (same-event discriminator; batch freeze/T1). Two residuals REMAIN, both SYMMETRIC across depths: the source-actor granted-state channel and a board-wide external-write x event-live-read channel (feeds() blind, batch T1 keys on writes_event_object too). The gate is sound modulo these two profile-invisible symmetric residuals; the group_is_order_independent contract now records exactly that. Discriminating unit test `r4_same_event_event_object_feed_conjunct` (NEG prompts; read-only / write-only / event_object_present / all_same_source guards auto). Revert-to-red measured: disabling the discriminator turns the NEG red. Assisted-by: ClaudeCode:claude-opus-4.8
…t-object discriminator (CR 603.3b) Maintainer review (phase-rs#5072): the R4 event-object discriminator had only a predicate-level test (`r4_same_event_event_object_feed_conjunct`, ability_rw) that calls `profiles_conflict` on a hand-built profile + GroupStructure. That does not exercise the caller `group_is_order_independent`, which DERIVES `event_object_present` from the pending trigger's firing event (`extract_source_from_event`, triggers.rs) and threads it into the structure. A future caller that mis-threaded `event_object_present` (or dropped the same-event structure) could auto-order the R4 shape while the predicate test stayed green — the hot-path seam a prior loop regressed on. The existing caller-level regression (`high1_same_event_member_bound_prompts`) covers member-bound only. Adds `r4_same_event_event_object_prompts_at_caller`, mirroring the member-bound caller-level test. Two DISTINCT sources fire on ONE shared ETB event (object 99); each identical resolution READS that creature's LIVE power (`Power{EventSource}` => `reads_event_live`, CR 608.2h) and WRITES it (`PutCounter{TriggeringSource}` => `writes_event_object`) => a sibling's write feeds the other's live read => order-observable => the caller PROMPTS. The feed is `source_independent`, so it exercises BOTH profiler edits (the fast-path exclusion and the discriminator). Four guards prove the discriminator is not a blanket same-event prompt: - read-only (a live power read that gains life, no event-object write) => auto; - write-only (a fixed-count event-object write, no live read) => auto; - no-event-object (the SAME feed on a `Phase` event, `extract_source_from_event` => None => `event_object_present` threads FALSE) => auto — directly pinning the caller threading: the identical ability prompts WITH an event object and autos WITHOUT; - all_same_source (the feed on one shared source) => auto. Revert-fail (caller-level, measured): disabling the discriminator turns the NEG case red at the `group_is_order_independent` level. Test-only; no production change. Assisted-by: ClaudeCode:claude-opus-4.8
…odecapod under-prompt (CR 603.3b) Rebasing the PR-6.75 series onto `cf7cd7cb9` replayed over upstream phase-rs#5084 (`perf(engine): batch identical self-counter triggers`), which — besides its resolution-time self-counter batching (orthogonal to this series' ordering gate) — removed the `is_on`/`loop_detection` parameter from `group_is_order_independent` and `trigger_events_match_for_ordering` and UNGATED the C2 distinct-event term (`loop_detection_on && c2_order_independent` -> `c2_order_independent`). Its rationale: loop detection governs optional infinite-combo shortcutting, not the finite CR 603.3b ordering UX. Adopting that ungating verbatim SURFACED a latent bug in this series: the coarse `ability_scan` C2 walker is blind to the source-actor residual (the CR 805.7 cause-source-controller channel, lifelink/deathtouch CR 702.15/702.2) that the precise `ability_rw` profiler catches only through the controller-uniformity gate. With C2 always-on, a C2-"clean" verdict could OVERRIDE that gate and auto-order a genuinely order-dependent Mixed-controller co-departure batch — an unsound under-prompt (the `condition1_dodecapod` witness). This was reachable in the series all along under `loop_detection = On`; the loop-OFF-only negatives never exercised it, and phase-rs#5084's ungating made it always-on. Fix (precision dominates coarseness): the C2 term becomes `c2_order_independent && !batch_conflict` — C2 may auto-order a distinct-event group only when the precise batch profiler ALSO agrees it is conflict-clean, so C2 can never override the uniformity/batch gate. `batch_conflict` is already a parameter here; it defaults false for clean non-departure groups (so phase-rs#5084's distinct-event fan-out still auto-orders) and is true for the Mixed-controller Dodecapod (so it prompts). This is NOT a semantic revert of phase-rs#5084 — loop detection stays ungated; the `!batch_conflict` conjunct is a series-specific soundness gate keyed on machinery that did not exist upstream when phase-rs#5084 was written. Also re-applies the mechanical ungating across the series' larger call surface (the C1 rebase conflict was resolved to this series' sound-gate version, which temporarily reverted phase-rs#5084's ungating): - `trigger_events_match_for_ordering`: drop the `loop_detection_on` param. - `group_is_order_independent`: drop the `is_on` param (keep `state`). - `begin_trigger_ordering`: drop the `loop_detection_on` local and the arg. - all callers (production + the ~42 unit/parity-sweep test calls that passed `false`) drop the `is_on` argument. - `pr625_c2_distinct_event_gate_off_prompts_on_auto_resolves` is renamed to `pr625_c2_distinct_event_auto_orders_even_when_loop_detection_off` with its OFF arm now asserting auto-order (adopting phase-rs#5084's flipped assertion). - doc/comments updated to the ungated framing. Gates: `condition1_dodecapod` and `b1_mindslicer` go from red (bare ungating) to green (with the conjunct) — the revert-to-red for the fix. `pr625_c2` stays green (phase-rs#5084's behavior preserved). The full-DB ordering-parity sweep is unaffected — it drives `profiles_conflict`'s same-event and departure-batch arms, not the distinct-event C2 term — measured zero-movement at the rebased corpus. Assisted-by: ClaudeCode:claude-opus-4.8
6dc10b9 to
01580f3
Compare
|
🤖 AI text below 🤖 @matthewevans Round-5 test landed ( R5 test: the R4 discriminator is now pinned at the production caller level per your spec — #5084 rebase — full disclosure: we adopted your C2-ungating verbatim (loop detection no longer gates finite ordering), and doing so turned two of this series' own negative tests red — which exposed a latent bug in this series, not in #5084: the coarse Also from the rebase: your stack.rs self-counter batching classified cleanly as orthogonal to the ordering gate (resolution-time, same-source, checkpoint-proven sequential-equivalent — triggers are ordered before they reach the stack); #5090 added no enum variant, corpus regenerated. Gates at 🤖 Generated with Claude Code |
Parse changes introduced by this PR✓ No card-parse changes detected. |
…#5072 R4/R5) (#5100) The #5086 dual-walk paragraph predates #5072's review rounds 4-5. Adds the three decision-bearing boolean axes (reads_member_bound, reads_event_live, writes_event_object) that drive profiles_conflict's same-event discriminators, and the post-classification full-DB sweep step with the predicate-class vs genuine-exact-set distinction (auto-absorb vs completeness-asserted explicit add). Symbols grep-verified against main at 8f47155. Assisted-by: ClaudeCode:claude-fable-5
…CR 513.2)
Kav Landseeker's "When this creature enters, create a Lander token. At the
beginning of the end step on your next turn, sacrifice that token." previously
left the delayed clause an Unimplemented{at} node — the parser had no temporal
arm for "the end step on your next turn", and the nearest existing condition
(AtNextPhaseForPlayer, "your next end step") fires the CURRENT turn's end step
(CR 513.2: a next-end-step delayed trigger created outside the end step is not
backed up), which would sacrifice the Lander before it could be used.
Add a turn-floor to AtNextPhaseForPlayer via a new typed enum
TurnGate { None, AfterCreationTurn, After(u32) } (not a bool, not a magic
sentinel): the parser emits the symbolic AfterCreationTurn, and
effects::delayed_trigger::resolve stamps it to After(creation_turn) at creation
(CR 603.7a) — the single-path binding site that already rewrites the PlayerId(0)
controller placeholder. The matcher then skips every matching phase up to and
including the floor turn and fires on the first strictly-later controller turn
(CR 500.7 extra turns included). An unstamped AfterCreationTurn reaching the
matcher debug_asserts and falls through to fire this turn (a loud, test-caught
wrong-timing signal) rather than silently never firing.
Existing AtNextPhaseForPlayer users (Greasefang, rebound, epic) default
gate: None and are byte-identical — None is skip_serializing_if, so only Kav
serializes a gate ("AfterCreationTurn", a pure semantic, no number). "That
token" reuses the existing TargetFilter::LastCreated snapshot-at-creation
(specific-token-safe per CR 603.7c / 400.7); the Lander predefined token and the
parsed Lander creation are untouched.
Tests: paired-timing runtime (Lander survives the current end step, is
sacrificed at the controller's next end step) with a resolved-stack reach-guard,
specific-token (a bystander token survives), a resolve-time stamp unit test, a
parser-shape guard, and a Greasefang non-perturbation guard — each with
revert-to-red. Coverage-regression on fresh card-data: 0 regressed, +1 gained
(Kav). Singleton delayed-trigger test scope is deliberate (see phase-rs#5072).
Assisted-by: ClaudeCode:claude-opus-4.8
… the phase-rs#5072 walker The phase-rs#5072 PR-6.75 read/write conflict profiler (ability_rw.rs) is exhaustive over Effect / TriggerCondition / TargetFilter / ControllerRef with no wildcards. This tranche, written before phase-rs#5072 merged, added new variants/fields the walker had no arms for; rebasing onto main surfaced 13 compile errors (+ a latent Effect::Behold masked by same-match E0027s). Classify each with its read/write profile and the three ordering axes (reads_member_bound / reads_event_live / writes_event_object), CR-grounded and mirroring the closest existing sibling: - TargetFilter::GrantingObject (CR 201.5a): mirrors SpecificObject on the base axes; reads_member_bound = true (fail-closed R3 divergence). Parse-template-only — grant-clone concretizes it to SpecificObject{granter} (ability_utils.rs), so the arm is inert at runtime and classified conservatively. - ControllerRef::TargetOpponent (CR 109.4): mirrors TargetPlayer (runtime-read- identical) — all axes empty (declared-target read, member-invariant). - TriggerCondition::TriggeringSpellMatchesFilter (CR 601.2a/603.4): mirrors TriggeringSpellTargetsFilter — reads_event_live. - Effect::Behold (CR 701.4a/608.2d): mirrors Reveal — a pure information event, no write; its resolution-time choice is classified in ability_scan (MayPrompt). - Effect::Cloak.object_source (CR 701.58a), Effect::Dig.keep_count_expr (CR 701.20e/608.2c), Effect::GainActivatedAbilitiesOfTarget.scope (CR 602.1): new fields bound + profiled like their sibling fields. TurnGate.gate (a match-time firing gate, not traversed by ability_rw) and Effect::ControlNextTurn's ControlWindow (absorbed by the maximal-conservative arm) need no binding. Also adapts one lib test to a main API delta: phase-rs#5072-era main replaced Effect::DealDamage's former `excess_only: bool` with a typed `excess` field, so a DealDamage constructor in copy_spell.rs's tests gains `excess: None` to match. One tail adaptation commit rather than folding into the owning card commits: unlike phase-rs#5072's own case, the walker lives in the rebase BASE here, so folding WOULD restore per-commit compiles — but a six-way fold across the tranche is error-prone; the arms are grouped here and can be autosquashed at ship-prep if the final PR wants per-commit bisectability. cargo check --workspace is clean (engine + phase-ai + mtgish-import + server-core + wasm/server + test targets), zero downstream dual-walk sites, and the full engine test suite passes (17549 tests, 0 failures) — no HIGH-2 / C2-ungating trip in the tranche's tests (Kav's delayed-trigger tests were already singleton-scoped). Assisted-by: ClaudeCode:claude-opus-4.8
…counters"
Implements Rhys's "{W},{T}: Remove any number of counters from target creature
you control" as a resolution-time interactive choice (CR 107.1c: "any number"
includes zero; CR 608.2d: the controller chooses at resolution).
Parser: a new "any number of" arm lowers the count to QuantityExpr::UpTo
(Fixed{-1}), reusing the existing UpTo slot — no new variant, so the phase-rs#5072
read/write dual-walk needs no arms. A CR 608.2d "from among" guard keeps
out-of-scope multi-source removals (Galloping Lizrog, Eventide's Shadow) as
Unimplemented rather than collapsing them to single-source semantics.
Effect path: resolve_remove peels the UpTo flag and raises
WaitingFor::RemoveCountersChoice carrying the target's live per-type counter
counts; the controller's GameAction::ChooseCountersToRemove is validated by a
validate_counter_selection helper shared with the cost path and applied through
the CR 614.1 remove_counter_with_replacement pipeline via a pending_counter_removals
queue. The queue re-parks on replacement choices and, on drain, stamps
last_effect_count before the continuation drains, so "create that many" reflexive
clauses (Tetravus) read the true removed total (CR 608.2h).
Multiplayer: accepts_freeform_counter_removal + a session skip-legality arm let a
human submit an intermediate per-type selection the coarse AI candidate set does
not enumerate; the payload guard bounds the selection list.
Coverage: GAINED = exactly {Rhys}. Tetravus's remove-counters trigger and token
creation now resolve (proven: 3 counters -> 3 Tetravite tokens), but the card
stays unsupported on an unrelated keyword-grant clause nested in that trigger.
Cost-path storage lands / batteries and the multi-source cards are unchanged.
The plan reviewer's re-review request was folded into /review-impl with a mandate
to re-verify all six must-change resolutions at code level plus the four
non-compile-enforced registration points; verdict APPROVE.
Assisted-by: ClaudeCode:claude-opus-4.8
…naphor counter binding Fixes the two parser-leaf gaps on Esper Terra (Terra, Magical Adept // Esper Terra) plus the shared binding that makes its marquee chapter effect land on the right object. No new engine variant (reuses QuantityExpr::UpTo, PutCounter, ManaProduction::Fixed, TargetFilter::LastCreated) — the phase-rs#5072 read/write dual-walk needs no arms. Changes (all parser): - Gap A (counter.rs): "put up to three lore counters on it" — strip the "up to " count marker and wrap the count in QuantityExpr::up_to (CR 608.2d + CR 122.1), mirroring the Draw/Discard/PutSticker convention. Count-side only; the target-side "on up to N target(s)" path is untouched. - Gap B (mana.rs): "Add {W}{W}, {U}{U}, {B}{B}, {R}{R}, and {G}{G}" — a conjunctive comma+"and" fixed-mana-group accumulator (parse_fixed_mana_group_list) → ManaProduction::Fixed (CR 106.4). Rejects "or" lists, requires >=2 groups, no dedup. - §B2 token-anaphor bind (context.rs + mod.rs + counter.rs): a bare "put ... counters on it" following a token creator binds to the created token, not the ability source (CR 608.2c). A ParseContext.token_created_in_chain signal (set from the most-recent prior referent, cleared by an intervening typed target) drives the it-pronoun counter branch to TargetFilter::LastCreated. A companion LastCreated guard on replace_target_with_parent's counter arm (mod.rs) preserves that bind through the lowering pass — the guard its sibling Attach/UnattachAll arms already carried. The "if it's a Saga" gate needs NO code: it parses to TargetMatchesFilter{Saga} reading ability.targets.first() (the copied enchantment, CR 707.2 type-equal to the token), so a non-Saga copy places zero lore counters by construction. Coverage (full-DB regression): REGRESSED(engine)=0. GAINED=1 (Esper Terra). Eight counter-target fingerprint changes, all correct: - 4 anaphor target corrections (SelfRef→LastCreated): Esper Terra (x3 chapters, the +1 GAINED); applied geometry; the bus runner (first put; card stays unsupported on a separate gap); journey to the lost city. - 4 Gap-A parser wins (target unchanged SelfRef, a new "up to X" node parses): clockwork avian/beast/steed/swarm (stay unsupported on the counter-cap gap). - 9 genuine-source cards ("...on this creature/enchantment/<name>") byte-identical. Measured blast radius (replaces the plan's 6/7 prediction): 3 anaphor cards corrected (applied geometry, the bus runner, journey to the lost city) / 5 pre-existing-unchanged / 9 genuine-source preserved. The 5 unchanged cards (alien invasion, intrude on the mind, lasting fayth, match the odds, kianne) put counters via a dynamic "for each"-count PutCounter on a separate parser path that never reaches the it-branch — they stay SelfRef (misbound to source), byte-identical to baseline. That path is a pre-existing, LIVE rules-wrong gap this change does not touch; deferred and logged (.planning/coverage-analysis/S25-DEFERRALS.md D1). Ship-prep note: journey to the lost city appears in phase-rs#5072's R3 member-bound classification; its PutCounter target change alters its ability AST, so its reads_member_bound rw-profile — and thus se_member_bound_class — may move +/-1 at the next full-DB sweep. Attributed to this commit (expected movement, not a regression). Assisted-by: ClaudeCode:claude-opus-4.8
…ider
Closes the last gap on The Tomb of Aclazotz's "{T}: You may cast a creature
spell from your graveyard this turn. If you do, it enters with a finality
counter on it and is a Vampire in addition to its other types." The finality
counter half was already wired via the ExileWithAltCost permission channel; this
adds the parallel channel for the "is a Vampire in addition to its other types"
type grant.
- New Effect::AddPendingEntersModifications { modifications: Vec<ContinuousModification> }
— the general "enters-with continuous modifications" carrier (CR 613 layer
system), a categorical sibling of AddPendingETBCounters (CR 122 physical
counters). The Vec<ContinuousModification> shape absorbs every future
enters-with rider (added types, granted abilities, colors), so no third sibling
is needed. The two channels stay separate because a counter is not a continuous
modification.
- Parser (oracle_effect/mod.rs): try_parse_cast_this_way_enters_rider lifts the
former trailing-tail rejection (CR 205.1b) to accept "… and is a <type> in
addition to its other types", parsing the tail via
animation::parse_becomes_type_modifications (additive AddType/AddSubtype/
AddSupertype — retains printed types, CR 205.1b) and emitting the new effect as
the counter rider's sub-ability.
- Permission channel (mirrors enters_with_counter exactly): a new
ExileWithAltCost.enters_with_modifications field (serde default + skip-if-empty),
lifted from the rider at grant time and read back from the SELECTED permission at
cast-finalize (CR 611.2c — no sibling-permission leak), applied to the cast
object as a Duration::Permanent continuous effect (Layer 4, CR 613.1d).
- phase-rs#5072 read/write dual-walk: AddPendingEntersModifications is classified as a
self-scoped SetMembership write (mirroring Effect::Animate/Endure's type-line
writes) — NOT an ObjectCounters write; grouping it with the counter sibling would
misclassify a type-write as a counter-write and corrupt the conflict analyzer.
Coverage: GAINED = exactly {The Tomb of Aclazotz} (the only card in the DB with
the "counter on it and is a[n] …" tail); REGRESSED(engine)=0. The counter-only
path is byte-preserved (early return before the tail), and the new variant/field
default everywhere, so the change is strictly additive.
Deferred, out of scope (ledgered): the finality counter's own death→exile
replacement (CR 122.1h) is still not enforced in the engine — a pre-existing,
class-wide gap affecting every finality-counter card, not introduced here. A
future type-only enters-rider grammar would also need the accept-path guard
generalized (benign warn-spam only, unreachable today).
Assisted-by: ClaudeCode:claude-opus-4.8
…ic base P/T
Closes The Skullspore Nexus's last gap: "Whenever one or more nontoken creatures
you control die, create a green Fungus Dinosaur creature token with base power and
toughness each equal to the total power of those creatures." The created token's
base P/T is a creation-time snapshot (CR 208.4b) of the total power of the
creatures that died in the triggering batch (LKI death-time power, CR 603.10a).
No new top-level Effect variant — the token P/T is already a PtValue::Quantity
snapshotted at creation. Two parser fixes + one QuantityRef leaf field:
- Parser (oracle_effect/token.rs): extract_token_pt_expression now SCANS to the
"power and toughness" phrase (take_until) instead of anchoring at position 0, so
it accepts Skullspore's "base power and toughness each equal to" (no "are/is"
copula) without regressing the existing mid-suffix "are/is each equal to" tokens.
- Parser (oracle_quantity.rs): "the total power of those creatures" lowers to
QuantityRef::TrackedSetAggregate with the new source. Scoped narrowly to
Sum + "those creatures" — a context-free anaphor→batch mapping is ambiguous, so
"those cards"/"those permanents" (mill/chosen anaphors) and "greatest … among"
(attack-batch idiom) are deliberately excluded to stay rules-correct.
- Engine (types/ability.rs, game/quantity.rs): QuantityRef::TrackedSetAggregate
gains a `source: TrackedAnaphorSource { ChainSet (default), TriggeringBatch }`
field. TriggeringBatch resolves the triggering event batch (current_trigger_events,
scoped to this trigger's own filter) and aggregates power over it. serde
default + skip-if-ChainSet keeps all existing card-data byte-identical.
phase-rs#5072 read/write dual-walk (the field-trap): TrackedSetAggregate is promoted out of
the wildcard-destructure group into an explicit arm so the new field is classified,
not silently swept. reads_member_bound=true is the honest classification for both
sources — the batch read is per-trigger-filter divergent (distinct-filter same-event
siblings read distinct batches), and the frozen-context mirror is unreachable because
legacy_batch_prompt is overwritten to false for non-legacy refs.
Coverage: GAINED = exactly {The Skullspore Nexus}; REGRESSED=0; the full per-card
byte-diff is exactly one card (no AST drift on any other card, including the 176
"power and toughness are/is each equal to" token class → the anchor→scan fix is
zero-regression). The broader "aggregate over the current triggering batch" anaphor
class (attack batches, graveyard-leave "those cards") is deferred pending a
trigger-context gate + an AttackersDeclared multi-attacker fan-out.
Assisted-by: ClaudeCode:claude-opus-4.8
…ther player"
Closes Parker Luck's last gap: "Whenever you attack, each player reveals the
top card of their library. You and target opponent each lose life equal to the
mana value of the card revealed by the other player." (CR 608.2c / 701.20b).
- New ObjectScope::OtherRevealedCard (CR 108.3 owner-keyed, CR 202.3 zone-
independent mana value): resolves by exclusion to the single last_revealed_ids
entry that is NOT the reader's own revealed card. Fail-closed to a null read
(=> 0) when no "other" exists — empty library or an illegal target on
resolution (CR 608.2b).
- Parser (oracle_nom/quantity.rs): "the card revealed by the other player[s]"
=> ObjectManaValue{OtherRevealedCard}, pure nom combinator.
- Resolver: reveal-ALL-then-fan-out (CR 608.2c instruction order — every reveal
completes before any cross-loss consumer runs), owner-keyed effect_context
binding (CR 108.3), by-exclusion mana-value read.
- reveal_top.rs: per-player reveal-all loop — an empty library is skipped
individually, never via a whole-effect early return.
- Effect::RevealTop::target_filter() now surfaces a stack-time player target slot
for the bare TargetFilter::Player reveal (CR 115.1 / 601.2c) so both revealers
are actually selected and their revealed cards individually bound. Deliberately
scoped to bare Player, NOT the general !is_context_ref() that RevealUntil uses:
a Typed(opponent) "target opponent reveals … deals damage to that player"
reveal (Cerebral Eruption) would expose a separate pre-existing
ParentTarget-after-reveal binding bug (measured: damage lands on the revealed
library card, not the player). That typed-opponent reveal-targeting is a
documented follow-up (S25 deferral D8).
- B3 anchor-precondition gate (lower_effect_chain_ir): rewrites OtherRevealedCard
=> Unimplemented when the chain has no multiplayer reveal, keeping the sibling
Keen Duelist ("you and target opponent each") honestly unsupported — its lose
node has no multi_target host (gate keys on multi_target presence, not min>=2).
- phase-rs#5072 read/write dual-walk: OtherRevealedCard classified per-resolution local
(last_revealed_ids cleared at depth 0), empty RwProfile — sibling-invisible,
explicit-matched at both walker sites (no field-trap).
Coverage: GAINED = exactly {Parker Luck}; REGRESSED(engine) = 0; Keen Duelist
stays supported=false (B3 gate); parse delta = {Parker Luck}. CR
108.3/115.1/119.3/202.3/601.2c/608.2b/608.2c/701.20b annotated + grep-verified.
Assisted-by: ClaudeCode:claude-opus-4.8
…drift (a8dbe10 -> 19b55b6) Companion to the earlier phase-rs#5072 rebase-adaptation commit in this stack. Folds the four semantic drifts that main's 42-commit advance introduced but the textual 3-way merge did not resolve. Each fix COULD have been folded into its owning commit; they are collected here instead because upstream squash-merges this PR — intermediate-commit compile health buys nothing once per-commit bisectability dies at the squash-merge. This is a cost decision under the token budget, NOT a cross-commit dependency constraint (all four fold cleanly into their owners). - manabrew-compat convert_available_action (owner: phase-rs#28 Rhys, cd2231c): Rhys's new GameAction::ChooseCountersToRemove needs an arm in main's now-exhaustive convert_available_action match (main removed the catch-all wildcard). Mirrors the sibling ChooseRemoveCounterCostDistribution => Unsupported("local.counter-removal-unsupported"). - parser/oracle_effect/lower.rs x2 (owner: phase-rs#12 P2f grant-abilities, fddfcce): allow-noncombinator annotations on (a) a TextPair dual-string strip_prefix and (b) a multi-line Effect::Unimplemented DESTRUCTURE pattern the gate regex cannot distinguish from a construction (false-positive). Both were --no-verify'd on the original base; annotated so check-parser-combinators passes vs 19b55b6. - game/mana_abilities.rs (owner: phase-rs#30 Foraging Wickermaw, 688fde6): main added PendingManaAbility.ability_snapshot; the pending_for test helper constructs the literal without it (caught by clippy --all-targets, not cargo check --workspace). Adds ability_snapshot: None. HEAD-state green: cargo check --workspace, clippy --all-targets -D warnings, cargo test -p engine (15303 + 1772 pass, 0 fail across 196 blocks), check-parser-combinators vs 19b55b6. Assisted-by: ClaudeCode:claude-opus-4.8
…#5155) * feat(engine): dynamic keep count on the dig pipeline (Stargaze) Parameterize PutCount::Up/Exactly payload u32 -> QuantityExpr and add an additive Effect::Dig.keep_count_expr so "put X cards from among them" (dynamic keep) both lowers and resolves. Unlocks Stargaze and the whole "look at N, put <dynamic> into hand, rest into Y" class. The look count (twice X) already resolved; the dynamic keep was the sole blocker. Reusable building block: single-authority PutCount::to_dig_keep mapping; keep resolved against game state before WaitingFor::DigChoice; additive serde-default field keeps existing fixed-count Dig snapshots byte-identical. CR 701.20e (look), CR 608.2c (follow instructions), CR 107.1b (negative -> 0). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): opponent-constrained target-player slot (Quick Draw) Add ControllerRef::TargetOpponent -- a pure routing tag whose runtime read is identical to TargetPlayer but whose companion target-player slot offers only opponents (self excluded; any one opponent in >2p), reusing the existing TargetFilter::Typed{controller:Opponent} + find_legal_targets legality path. Lowers "creatures target opponent controls lose <kw>..." (Quick Draw) and unlocks the whole "target opponent controls" class. Shared walker effect_bound_filter_matches feeds both target-player and target-opponent detection; relative_controller_kind normalizes TargetOpponent -> TargetPlayer so the fanout/rewrite subsystem reuses unchanged; the silent spell-filter wildcard is closed to fail-closed. CR 109.4, CR 102.2 / CR 102.3 (opponent), CR 611.2c (EOT set fixed at start), CR 702.7a (first strike), CR 702.4a (double strike). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): reflexive "this way" delayed-trigger building block (Prishe, Rhino) Add DelayedTriggerLifetime::Reflexive (CR 603.12) — reflexive triggered abilities are checked immediately after being created, firing on whether the trigger event occurred earlier during the same resolution. Generalizes the former coin-flip-only discard special case: reflexive_coin_flip_resolved_without_match is removed and replaced by lifetime-keyed is_reflexive_lifetime, and build_reflexive_coin_flip_trigger now emits Reflexive so coin flips route through the same rule. Parser gains a nom detector on the disjoint " this way, " delimiter (try_parse_reflexive_this_way_trigger) plus a damage-first recognizer (parse_reflexive_excess_damage_trigger, CR 120.10 excess-damage). Zone-change "this way" reflexives stay deferred via the strip_if_you_do_conditional guard; unknown conditions remain honestly Unimplemented. Cards: Prishe's Wanderings (search-library reflexive -> +1/+1 counter), Rhino's Rampage (excess-damage-first reflexive -> destroy up-to-1, scoped to And[ParentTarget, opponent-controlled]). Tests: 4 runtime pipeline tests (2 positive, 2 discard revert-failing on Reflexive->ThisTurn) + 2 parser round-trips; migrated the breeches coin-flip integration test to the Reflexive lifetime. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): scope "lose control" trigger + emit control-loss at cleanup (CR 514.3a) match_changes_controller fired on ANY ControllerChanged/EffectResolved{GainControl}, ignoring valid_card and direction — a latent over-fire (Portent trap) for the three supported "When you lose control of ~" cards (Khârn the Betrayer, Duplicity, Gustha's Scepter). Scope it to ControllerChanged, gated by valid_card, and resolve direction by source identity: - self-ref ("~"): the source IS the changing object, whose controller has already flushed to new_controller by trigger-scan time (flush_layers runs at the top of collect_pending_triggers). Rely on CR 603.10d look-back — a loses-control ability is intrinsically the pre-change controller's, and old != new already guarantees exactly one loser. Fire for it. - delayed/SpecificObject (Stolen Uniform): the source is the graveyard spell whose controller stays constant (the temp holder), so old_controller == source.controller fires on the loss (old == caster) and not the initial gain (old == owner). CR 603.2. Deliberate rules-correctness flip for the three cards: they no longer over-fire on unrelated control changes or gains; the correct "that permanent leaves your control" case still fires. Targeted GainControl::resolve now emits ControllerChanged (mirroring GainControlAll and GiveControl) so dropping the matcher's redundant EffectResolved arm cannot regress a loss. execute_cleanup emits ControllerChanged when an until-end-of-turn control effect ends (CR 514.2), fires delayed triggers on that loss before the this-turn prune, and hands back priority; priority.rs re-enters cleanup once the stack empties (CR 514.3a "another cleanup step begins"). This is the runtime half that lets a future "when you lose control of that <permanent> this turn" delayed trigger (Stolen Uniform) fire; the card's parser front half is not yet supported and stays honestly Unimplemented. Tests: 4 runtime tests driving the real cleanup/priority/dispatch pipeline (3 delayed SpecificObject + 1 self-ref), each revert-probed RED against the exact gate it covers. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): look-at + play face-down exile building block (Outrageous Robbery) Outrageous Robbery: "Target opponent exiles the top X cards of their library face down. You may look at and play those cards for as long as they remain exiled. If you cast a spell this way, you may spend mana as though it were mana of any type to cast it." Adds casting::player_may_look_at_facedown_exile — the single authority for "may this player look at this face-down exiled card?", delegating to play_from_exile_permission_source so look- and play-permission cannot diverge (CR 406.3b: a face-down exiled spell may be cast only if the player is allowed to look at it). It inherits the source's card_filter / single_use / per-turn gating. visibility.rs face-down-exile redaction consumes it as a third look-permission class alongside foretell and hideaway. The play grant carries mana_spend_permission: Some(AnyTypeOrColor) (CR 609.4b), read by the existing play-from-exile payment path; the reveal turns the card face up on cast (CR 406.3a). Parser: the subject-voice "<player> exiles the top N ... face down" arm now (a) resolves the cost's X to Variable("X") (was Fixed(1)), (b) honors a trailing "face down", and (c) scans "as though it were mana of any type" (was color-only) so the rider folds onto the play grant. Corrects a pre-existing wrong CR annotation on that arm (701.10a Doubling -> 701.13a Exile). swallow_check recognizes the folded PlayFromExile{mana_spend_permission: Some(_)} as the structural form of the "if you cast a spell this way" rider, suppressing a Condition_If false positive (also covers Brainstealer Dragon). Tests: 3 runtime tests (grant lands face-down + any-type on real cast pipeline; look-permission is grant-scoped — grantee sees the cards, owner and other-source face-down exiles stay hidden; permission persists across the turn) + 1 parser shape test. Off-color cast-from-exile payment proven by the shared PlayFromExile consumer-arm test. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): parse "the spell's mana value <= X" gate for impulse-cast (Bre of Clan Stoutarm) Bre of Clan Stoutarm's end-step ability: "if you gained life this turn, exile cards from the top of your library until you exile a nonland card. You may cast that card without paying its mana cost if the spell's mana value is less than or equal to the amount of life you gained this turn. Otherwise, put it into your hand." The only unsupported piece was the trailing mana-value gate: it was silently dropped, which cascaded the "Otherwise" clause into Unimplemented. Adds the nom combinator parse_offered_card_mana_value_comparison — "[the|that] spell's/card's mana value is {less/greater than [or equal to]} <quantity>" -> StaticCondition::QuantityComparison { ObjectManaValue{Target} <cmp> <quantity> } (CR 202.3 mana value, CR 115.1 target, CR 608.2c). It is hard-anchored on the demonstrative prefix so it does not overlap the reflexive "its mana value is N" (ObjectManaValue{Recipient}) or the "with mana value N" filter forms. Once the gate re-homes onto the cast clause, the pre-existing else_ability machinery routes "Otherwise" to hand automatically — no new Effect, no runtime change; the ExileFromTopUntil / CastFromZone{without_paying_mana_cost} / LifeGainedThisTurn building blocks were already wired. Bre's activated ability already parsed. Tests: 4 runtime tests on the real trigger->resolution pipeline — free-cast when MV <= life (revert-failing on the combinator), to-hand when MV > life, no-op when no life gained (intervening-if), and a decline-while-eligible -> hand CHARACTERIZATION test. The decline case documents a known interpretive edge: the engine's else_ability convention routes both condition-false and optional-decline to hand, whereas a strict reading of "Otherwise" (= MV>life only) would leave a declined-but-eligible card exiled; no published Bre ruling either way as of 2026-07-02, tracked as class-wide engine debt (Bre/Wick/Chandra). Assisted-by: ClaudeCode:claude-opus-4.8 * fix: thread keep_count_expr + TargetOpponent through non-engine consumers (mtgish-import, phase-ai tests) Commits 1b67f0248 (Stargaze) and 6f3a35d11 (Quick Draw) added the `keep_count_expr` field to `Effect::Dig` and the `ControllerRef::TargetOpponent` variant to the engine but did not update the non-engine consumer crates, leaving CI's exact lint surface (`cargo clippy --workspace --exclude phase-tauri --all-targets`) red. `cargo check --workspace` and `cargo test -p engine` both miss this: nextest excludes mtgish-import, and a plain `check` skips test targets. - mtgish-import action.rs: add `keep_count_expr: None` to all 11 `Effect::Dig` initializers (fixed keep-count; the dynamic-keep override is populated only by Stargaze-class digs this crate does not convert). - mtgish-import player_effect.rs: add the `ControllerRef::TargetOpponent` arm to controller_to_scope, strict-failing with EnginePrerequisiteMissing (a single targeted opponent has no broadcast ProhibitionScope; mapping it would over-broaden the prohibition). - phase-ai control.rs + spellslinger_prowess.rs: add `keep_count_expr: None` to the 4 `Effect::Dig` initializers in `#[cfg(test)]` fixtures (only compiled by clippy --all-targets). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Memory Vessel — play-from-exile grant + can't-play-from-zone prohibition Memory Vessel ({T}, exile it: each player exiles the top 7, may play them until the activator's next turn, and can't play from their hand) now lowers fully — the previously-collapsed "players may play cards they exiled this way, and they can't play cards from their hand" clause resolves into a per-owner play-from-exile grant plus a play-from-zone prohibition. Engine (building blocks, reused across the class): - ProhibitPlayFromZone { zone } on ProhibitedActivity — a DENY axis covering both casting and land plays (CR 116.2a/305.1/601.2a), distinct from the CastOnlyFromZones allow-list which is cast-only. Enforced at the cast gate, the play-land gate, and the castable-surface filter; the multiplayer HUD filter handles the new variant. - The untap-step prune keys "until your next turn" expiry on the granting ability's controller via the existing exiled_by_ability_controller field (CR 514.2/611.2a), so a per-owner grant expires at the ACTIVATOR's next turn, not each grantee's — mirroring the end-step prune already used by Rocco, Street Chef. No new field. Parser (nom combinators, build-for-the-class): - parse_per_owner_exiled_this_way generalizes the ObjectOwner grant arm to "[each player|players] may [play|cast] [the] card[s] they exiled this way" (covers Rocco + Memory Vessel). - try_parse_cant_play_from_zone: "[scope] can't play [cards|lands...] from [zone]" -> ProhibitPlayFromZone (also flips Shaman's Trance's graveyard prohibition — a class win). - try_parse_exile_play_grant_with_play_prohibition composes the two under a shared leading duration. Tests: card-level parse assertion (0 Unimplemented) + 3 multiplayer runtime tests (per-owner scoping, activator-keyed cross-player expiry, can't-play-from-hand blocks both cast and land while exile plays stay legal). Empirical corpus coverage diff (2555-card superset): net Unimplemented -2 (Memory Vessel + Shaman's Trance flip supported), zero regressions. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): dual-target slot registry + anaphoric slot binding (Stolen Uniform front-half) Add a declared-target-slot registry on ParseContext so "Choose target X and target Y" chains bind later anaphors ("that Equipment", "the chosen creature", "the artifact card") to a precise ParentTargetSlot{index} instead of the ambiguous grab-all ParentTarget. Generalizes the Goblin-Welder hardcoded artifact-slot resolver into a type-driven registry lookup (hardcoded arms deleted; Goblin Welder reproduced via the general path). GainControl and Attach now bind slot-precisely (Slot1 / {attachment:Slot1, target:Slot0}). Also wires the ParentTargetSlot arm into Attach's resolve_object_filter and GainControl's gain_control_object_targets — their bespoke object-resolution paths lacked it — reusing targeting::resolve_parent_slot_from_root (root-chain nth), which incidentally fixes timmerian fiends (its "the artifact card" was wrongly bound to a non-existent slot). Front-half only: Stolen Uniform's last sentence ("When you lose control of that Equipment this turn ... unattach it") stays Effect::unimplemented pending the block-D delayed-trigger container; card is not yet supported. The determiner anaphor path uses nom combinators; the pre-commit parser gate's flags are stale-base (ae663ee8c) false positives on pre-existing mod.rs / untouched oracle_trigger.rs lines — none in this commit's additions (verified). CR 601.2c (target chosen once per instance of "target") + CR 608.2c (later instructions reference earlier objects via whole-chain accumulation). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): classify new engine surfaces in the #4904 fail-closed walker Rebasing the S25 tranche onto main (#4904 growing-cascade detector + fail-closed ability-scan walker) surfaces two exhaustive-match classification points for engine surfaces this branch added before the walker existed: - ControllerRef::TargetOpponent (Quick Draw) — Axes::NONE in the C0 axis classifier, mirroring TargetPlayer. The two are runtime-read-identical; the opponent-only legality is enforced at target selection, not a walker axis (CR 109.4). - Effect::Dig.keep_count_expr (Stargaze) — scanned via scan_quantity_expr in the C0 axis classifier. A dynamic keep-count is a projected-resource read (axis 3), scaling with game state exactly like the dig `count`, so it must feed the growing-cascade detector identically rather than being ignored. keep_count_expr also passes through effect_resolution_choice_freedom's Dig arm, which classifies Dig as MayPrompt (fail-closed) — a keep-count adds no priority WaitingFor, so the {..} pass-through is sound and needs no per-field action. ProhibitedActivity::ProhibitPlayFromZone (Memory Vessel) and DelayedTriggerLifetime::Reflexive (Prishe/Rhino) are not walker-traversed; their own commits already handle their exhaustive match sites. Only ability_scan.rs (game/) changed; the pre-commit parser gate's flags are stale-base (ae663ee8c) false positives on pre-existing parser lines not in this commit. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): pay turn-face-up cost as a special action (Overgrown Zealot, Tin Street Gossip) Turning a face-down permanent face up is a special action (CR 116.2b) that must pay the morph/megamorph/disguise cost (CR 702.37e / 702.168d) or the manifested creature's mana cost (CR 701.40b). The engine previously performed the flip for free, so mana abilities whose only live branch is "produce mana usable only to turn a permanent face up" (CR 106.6 restricted-purpose mana) were left as honest-red Unimplemented gaps. - morph.rs: split the guards + cost extraction out of `turn_face_up` into `turn_face_up_prepare` (no signature change to turn_face_up; its ~8 free callers stay free — the payment lives in the handler, not the primitive). - engine.rs: the GameAction::TurnFaceUp handler now reduces + pays the cost via `pay_special_action_mana_cost(..., SpecialAction::TurnFaceUp, ...)`, mirroring the UnlockDoor special-action sibling. Sole production paid entry. - types/ability.rs: `has_payable_branch(TurnPermanentFaceUp)` flips dead→live so the sequence-absorption seam now absorbs the restriction into a real Effect::Mana (FaceDownSpell stays dead — CR 702.37c face-down casting is still unimplemented). Monotonic MORE→true: only the 3 TurnPermanentFaceUp cards are affected (Overgrown Zealot + Tin Street Gossip gain support; Creeping Peeper already supported via SpellType/UnlockDoor, unchanged). Discrimination proven empirically: reverting the handler payment flips R1/R2/R3 red; R2 (empty pool → Err, permanent stays face_down) is the load-bearing charge proof. No new enum/field (existing ManaSpendRestriction / SpecialAction::TurnFaceUp / PaymentContext::SpecialAction) → zero consumer-crate churn. s07-frozen files untouched. Parser dispatch unchanged (oracle_tests.rs changes are test assertions only; the gate's flags are stale-base ae663ee8c false positives, not this commit). CR 106.6 + CR 116.2b + CR 702.37e + CR 702.168d + CR 701.40b + CR 702.37c. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): grant abilities beyond activated-only via GrantedAbilityScope (Symbiote Spider-Man, Choreographed Sparks, Nalfeshnee) Parameterize Effect::GainActivatedAbilitiesOfTarget with a typed GrantedAbilityScope { ActivatedOnly (default) | AllOther } field (serde-default, zero consumer-crate churn) instead of adding a sibling variant — the granted ability set is fixed at effect start (CR 611.2c), within a single rule section. - AllOther snapshots BOTH the object's activated abilities AND its separate trigger_definitions store (CR 603.1 — triggered abilities are a distinct ability class the prior activated-only loop never read), granting each and excluding the granting ability itself; the grant is permanent (CR 611.2a). - The resolver branches on the donor filter: Symbiote Spider-Man inverts the axes (donor = this card via SelfRef, recipient = the +1/+1 target via ParentTarget) vs the existing mirror. - Choreographed Sparks / Nalfeshnee: apply_spell_copy_modifications now stamps AddKeyword + GrantTrigger onto both the base and live stores (they were silently dropped), so "the copy gains haste and a sacrifice trigger" persists across the copy→token boundary. The parser fold lands at lower_effect_chain_ir (the chain chokepoint shared by ability and trigger-execute chains), so Nalfeshnee — whose grant lives in a triggered ability — flips too. Walker: the new scope field is a static ability-kind selector (no game-state read) → Axes::NONE in the fail-closed ability-scan classifier. Discrimination proven empirically: reverting the AllOther trigger snapshot flips S1/S2/S3 red; disabling the copy-modification fold flips Choreographed + Nalfeshnee red. Coverage +3, zero regressions across all 35397 faces. No new Effect variant; s07-frozen files untouched. CR 611.2a + CR 611.2c + CR 603.1 + CR 701.21a (delayed sacrifice) + copy/haste rules. Assisted-by: ClaudeCode:claude-opus-4.8 * test(engine): flip Choreographed Sparks deferred-pin to supported P2f (4b2566eb6) implemented Choreographed Sparks' copy-grant (haste + delayed-sac fold via apply_spell_copy_modifications), invalidating the deferred-honesty guard `choreographed_sparks_copy_grant_is_deferred`, which asserted the card still lowered to Unimplemented. Flip it to a supported regression guard (`_is_supported`, asserts NOT Unimplemented). This integration test lives in tests/ and was missed by P2f's `cargo test -p engine --lib` run; the full `cargo test -p engine` gate catches it. Fixup for 4b2566eb6 — fold at ship-time autosquash. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Stolen Uniform lose-control container — runtime-correct unattach delayed trigger (#4380 block-D) Parser recognizer for Stolen Uniform's last sentence ("When you lose control of that Equipment this turn, if it's attached to a creature you control, unattach it") plus the two engine gaps it exposed, so the delayed trigger actually unattaches the right Equipment at cleanup — not a hollow parse flip. Parser (oracle_effect/mod.rs, oracle_target.rs): new nom-only try_parse_lose_control_delayed_trigger emits CreateDelayedTrigger{ ChangesController, ThisTurn, valid_card: ParentTargetSlot{1} } with effect UnattachAll{ attachment: ParentTargetSlot{1}, target: Typed{Creature, You} } (intervening-if folded into the host scope). Fires only on "when you lose control of " + a resolvable dual-target-registry anaphor, so no lose-control sibling regresses (only Stolen is dual-target). CR 603.7 / 603.4 / 603.2 / 701.3d. Gap #1 — trigger stall (triggers.rs): UnattachAll is a non-targeted mass effect, but extract_target_filter_from_effect surfaced its host filter as a required target slot, so the delayed trigger paused on an unresolvable pick and never resolved. Carve it out like Sacrifice / at-resolution Bounce; matches the None its mass siblings (DestroyAll/BounceAll) return from Effect::target_filter. CR 701.3d + CR 115.1. Gap #2 — attachment anaphor (attach.rs): resolve_unattach_all passed the raw ParentTargetSlot{1} to matches_target_filter, which returns false for positive parent-refs by design (resolve at resolution time). Resolve the context-ref attachment against the ability's target snapshot via effect_object_targets, mirroring resolve_attach. Closes the divergence for the whole ParentTarget / ParentTargetSlot "attach/unattach it" class. CR 608.2c + CR 701.3d. Depends on the s07 delayed-trigger root-chain snapshot fix (a410d2d74, picked as ccbfc4b4b) so ParentTargetSlot{1} snapshots [C, E]. Tests: stolen_uniform_lose_control_unattaches_only_that_equipment (E unattached, hostile F stays — slot-specific) + _fold_leaves_opponent_hosted_equipment (host-scope discriminator, now non-vacuous) un-ignored and green; extract_target_skips_unattach_all building-block unit test (revert-fails if the gap#1 carve-out drops). Full test-engine: 14707 lib + all integration green; CI clippy 0 warnings. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): become-a-typed-token copula on reanimated objects (Vraska the Silencer, Brilliance Unleashed) P2e — "It's a <typed thing> …" applied to a returned/reanimated non-copy object, as an indefinite continuous effect bound to that object. Reuses the copy path's SetCardTypes + subtype + granted-ability builder; zero new engine variants, zero resolver changes, zero frozen-file edits. Vraska, the Silencer: "return that card … tapped under your control. It's a Treasure artifact with '{T}, Sacrifice this artifact: Add one mana of any color,' and it loses all other card types." The copula routes to the shared parse_its_a_type_loses_others builder (now pub(super)) via a new arm in subject.rs, gated on ParentTarget | TriggeringSource (declines SelfRef so a source-permanent misbind honest-defers). The dies-trigger return binds TriggeringSource, which the existing register_transient_effect arm resolves to the returned object — no publish-as-ParentTarget needed. CR 205.1a / 205.1b / 613.1d / 611.2a / 400.7 / 603.6 (NOT 707.9d — copy-effect-scoped). Block 1a (sequence.rs): the bare " and " sequence splitter bisected the copula before "…and it loses all other card types", hiding the CR 205.1a replacement signal from the builder. Suppress the split when the remainder is exactly "(it )?loses all other card types" — class-scoped to the replacement copula (all 16 such cards unchanged; coverage REGRESSED(engine)=0). Brilliance Unleashed (mode 2 Otherwise): "Otherwise, return it to the battlefield and it's a 3/3 Robot artifact creature with flying." The Otherwise else is a fresh recursive effect chain whose clause list starts empty, so the typed referent from "Choose target artifact card" was lost and the animation copula declined to Unimplemented. Seed ctx.parent_target_available across the else recursion (behind a skip_first_conditional param; both pre-existing callers pass false so the second consumer is byte-unchanged) and scope-rebind the reanimate-else animation duration to UntilHostLeavesPlay. Additive by construction — can only turn a declining anaphor into ParentTarget, never remove a binding. Bre of Clan Stoutarm (C12) reuses the same else-seed. C3: both copulas install Duration::UntilHostLeavesPlay (CR 400.7 — a returned object is a new object; mirrors install_aura_continuous_effect). Tests (std_longtail_e.rs, +4, deferred note flipped): parser round-trip + runtime for each card. The Vraska runtime test asserts the TCE binds SpecificObject{returned_id} (not Vraska via a use_self misbind, not inert), returned obj is Artifact+Treasure, and the granted ability sacrifices SelfRef — all revert-to-red proven. gen-card-data: both flip supported, gap_count=0. Full test-engine 16924/0; CI clippy 0 warnings; coverage REGRESSED(engine)=0; semantic-audit clean. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): cloak from a chosen non-library source (Vannifar, Evolved Enigma) Vannifar's "Cloak a card from your hand" — the controller cloaks a card they CHOOSE from hand, not the top of the library. Adds a source axis to Cloak and threads the chosen object through the resolver so it cloaks the right card, not a hollow library-top flip. Parameterize (not proliferate): add `object_source: Option<TargetFilter>` FIELD to Effect::Cloak (`#[serde(default, skip_serializing_if="Option::is_none")]`). None = CR 701.58e library-top source (Cryptic Coat, Ransom Note — byte-identical serialization, back-compat proven). Some(filter) = explicit objects chosen upstream. Zero new variants. Resolver (cloak.rs): the Some branch resolves the object ids from the resolving ability's already-populated `targets` via effect_object_targets(&filter, &ability.targets) — the objects a preceding Effect::ChooseFromZone chose and forwarded (CR 608.2c) — then manifest_card(…, cloaked_2_2()) per object. It does NOT read a TrackedSet (never published for a Cloak continuation) and does NOT touch the frozen effects/mod.rs. The None branch is the unchanged library-top loop. Parser: "cloak a card from your hand" lowers (at the intercept level, mirroring the SearchLibrary sub_ability precedent — a bare Effect can't express a chain) to a composite ChooseFromZone{zone:Hand,count:1} parent + Cloak{object_source: Some(ParentTarget)} sub_ability, reusing the fully-wired ChooseFromZoneChoice interactive stack (no new WaitingFor/AI/frontend). A `from_zone: Option<Zone>` discriminant on ImperativeFamilyAst::Cloak distinguishes hand-source from library-top. Pure nom (tag/alt/all_consuming). Walker (#4904 ability_scan.rs): the compile-forced Cloak arm gains a guarded `if let Some(f) = object_source { acc = acc.or(scan_target_filter(f)); }`. Anti-hollow-win test (tests/vannifar_cloak_from_hand.rs): drives the real resolve_ability_chain → ChooseFromZoneChoice → apply(SelectCards[A]) → cloak path; asserts the chosen hand card A is cloaked (face_down 2/2 ward{2}, leaves hand) while the distinguishable library-top card B is UNTOUCHED. Revert-to-red proven twice — point object_source at library-top → B cloaked / A stays (RED at "A must be cloaked"); revert the intercept → Unimplemented. Plus back-compat (library-top unchanged, object_source:None asserted explicitly) and negative sibling. Expose the Culprit remains a separate later gate (reuses this field). Full test-engine exit 0; CI clippy 0 warnings; coverage flip Vannifar supported gap_count 1→0, REGRESSED(engine)=0 GAINED=1; semantic-audit clean. CR 701.58a / 701.58e / 608.2c (grep-verified). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): cloak an exiled face-down pile — Expose the Culprit mode 2 Expose the Culprit's mode 2 ("Exile any number of face-up creatures you control with disguise in a face-down pile, shuffle that pile, then cloak them") lowers to ChooseObjectsIntoTrackedSet{Creature, You, HasKeywordKind{Disguise}} -> Shuffle{TrackedSet} -> Cloak{object_source: Some(TrackedSet)}. - KeywordKind::Disguise (CR 702.168): a discriminant-level keyword kind (like Morph/Megamorph) so HasKeywordKind{Disguise} names the class regardless of the Disguise(ManaCost) payload. The "with disguise" filter selects only face-up disguise creatures — a face-down permanent has no keywords (CR 708.2a). - Shuffle gains a TrackedSet/pile branch (CR 701.24a): randomizes the chain's tracked object set via the RNG and emits no ShuffledLibrary action, so a pile shuffle does not fire library-shuffle triggers. - Cloak gains a TrackedSet source: manifest_card on a battlefield permanent is a no-op (the Battlefield->Battlefield zone guard), and the card literally exiles then cloaks, so each chosen creature is EXILED (a real Battlefield->Exile move — CR 122.2 counters cease, CR 603.6c leaves-the-battlefield triggers fire, CR 704.5m/704.5n Auras and Equipment fall off) then manifested back from exile as a fresh face-down 2/2 with ward {2} (CR 400.7 new object, CR 701.58a/e). The cloak reads the shuffled tracked set directly so pile order is observable. Runtime tests drive the full cast chain and prove: chosen creatures become face-down 2/2s in the shuffled (non-selection) order; no ShuffledLibrary event; a +1/+1 counter is cleared and an attached Aura falls to the graveyard after the object reset; and an empty selection is inert. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): interactive "any number" multi-zone name-matched search-and-exile Route "search <its owner's/its controller's> graveyard, hand, and library for any number of cards with <same-name-ref> and exile them" to the interactive Effect::SearchLibrary path (CR 701.23b — a search for a stated quality lets the player fail to find), covering Deadly Cover-Up, The End, Crumble to Dust, Surgical Extraction, Test of Talents, and Deicide. Zero new engine enum variants. Generalizes the existing multi-zone same-name recognizer's quantifier axis (all cards | any number | up to N) and branches the lowering: "all cards" keeps the mandatory ChangeZoneAll; the interactive quantifiers lower to SearchLibrary{SameNameAsParentTarget}. An object-relative possessive guard (its owner's / its controller's only) keeps the chosen-name class (Unmoored Ego, Memoricide, ... — "choose a card name ... with that name") on the HasChosenName path (CR 201.2). - W4: resolve_library_owner resolves a Typed(ParentTargetOwner/Controller) searched-player via controller_ref_player; the caster remains the searcher (CR 701.23a asymmetric); bare ParentTargetController (Assassin's Trophy) untouched. - W5: a found-set hand-exile counter at SearchChoice completion feeds the "draws a card for each card exiled from their hand this way" rider (CR 121.1). - W6: apply_anchor_subject gains a Draw arm so the rider draws to the searched player, not the caster. - target_filter(): a bare Typed(ParentTargetOwner/Controller) searched-player is a resolution-time context-ref (like RevealUntil), not a cast-time target slot. Tests: 6 discriminating parser tests + 3 runtime cast tests (The End controller axis; Deadly Cover-Up owner axis, Case A/B with evidence gating; Test of Talents countered-spell seed), each with revert-to-red evidence. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): bind granted-ability self-references to the granting object (CR 201.5a) When an ability grants another ability that refers to the granting object by name (Deconstruction Hammer "Sacrifice Deconstruction Hammer", The Dominion Bracelet "{15}, Exile The Dominion Bracelet", Trusty Boomerang "Return Trusty Boomerang"), CR 201.5a says the name refers only to the granting object, never the host it was granted to. Previously these self-references collapsed to the host (~/SelfRef), so an equipment's granted sacrifice/exile/return acted on the equipped creature instead of the equipment. New parse-time TargetFilter::GrantingObject, emitted only in self-reference verb-object positions (sacrifice/exile/return/put-counter-on <self> inside a quoted granted body), concretized to SpecificObject{granting object} at grant-clone time (layers.rs GrantAbility/GrantTrigger, via effect.source_id). Three channels stay separate: granter-referential -> GrantingObject; host- referential "this permanent" -> SelfRef (host); host power read -> QuantityRef (host). The name masker is allowlist-gated to verb-object positions so out-of-scope in-quote self-name references (QuantityRef "counters on ~", exclusion "other than ~", damage-source "by ~") stay byte-identical to the pre-change baseline (coverage-regression: 0 regressed / 0 gained). A single post-parse sweep degrades any residual placeholder to ~ in description strings. Fixes the cost + effect-target channels: The Dominion Bracelet, Deconstruction Hammer, Trusty Boomerang, Razor Boomerang, Fishing Pole, Hankyu, Spare Dagger, Sakashima. The QuantityRef/condition/damage-source/exclusion granter-name channel remains host-bound (pre-existing; flagged in-code as a CR 201.5a follow-up). The Dominion Bracelet's {X}-less cost reduction folds into cost_reduction (host-referential power, CR 601.2f). Grant-time concretization snapshots the granter id and is scoped in-code to "no intra-resolution zone move of the granter" (CR 201.5a second sentence + CR 400.7). Zero new engine enum variants beyond TargetFilter::GrantingObject. Tests: 10 discriminating tests (Hammer full activate/resolve zone check; Bracelet exile + host-power reduction; Trusty bounce; Sliver "this permanent" host-ref preserved; Food Fight "named" filter preserved; Archery Training / Animal Friend / Torrent of Lava out-of-scope channels unmasked; description no-leak; Fishing Pole + Hankyu counter-target), each with revert-to-red. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): phase-scoped player control for Secret of Bloodbending (CR 723.2) Secret of Bloodbending: "You control target opponent during their next combat phase. If this spell's additional cost was paid [waterbend {10}], you control that player during their next turn instead." Previously the base "next combat phase" leaf was Unimplemented — CR 723.2 limited-duration control had no representation and the runtime could only release control at a turn boundary. Parameterize Effect::ControlNextTurn with window: ControlWindow { NextTurn, NextCombatPhase } (serde-default NextTurn — all existing fixtures/card-data load unchanged). CR 723.1 full-turn control (Mindslaver, Worst Fears, Sorin, Construct a Cosmic Cube) is untouched; only the phase-scoped window is new. Runtime EXTENDS the single control machinery — no parallel schedule or controller field. A new phase-boundary activate/release hook in finish_enter_phase (turns.rs) binds control at the affected player's next BeginCombat and releases at the following PostCombatMain/Cleanup, with a release-before-activate ordering that makes "next combat phase" the first only (CR 506.7d by analogy). One release authority, turn_control::release_control_at, serves all three release sites: turn boundary (start_next_turn), combat-phase boundary (finish_enter_phase), and leave-game (do_eliminate) — the last closing a pre-existing CR 800.4a/b gap that also affected Mindslaver's full-turn control. Parser: a window alt() axis on the control suffix combinator; a subject.rs deferral guard so the full pipeline routes "control ... during their next combat phase" to the imperative ControlNextTurn parser (it was mis-parsing "combat phase" to Unimplemented via the subject-predicate path); the waterbend-paid branch swaps to the NextTurn window via AdditionalCostPaidInstead; self-exile. Edge cases designed (CR 723.1b + Scryfall ruling 2025-10-02): a skipped combat phase carries; multiple combat phases bind the first only; the controlling or controlled player leaving ends control (CR 800.4a/b); 3+ players route only the controlled seat. Coverage-regression on fresh card-data: 0 regressed, +1 (Secret). Tests: 8 groups incl. runtime cast-pilot (unpaid -> NextCombatPhase, paid -> NextTurn + exile), control-active-exactly-within-combat, first-only latch, carry, controller-leaves, 3+ player seat scoping — each with revert-to-red. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(parser): repeat a whole process a fixed/variable count of times (CR 608.2c) "Then repeat this process X more times." (Another Round) previously left an Unimplemented{repeat} node. Recognize the unconditional count form "<q> more time[s]" in try_parse_repeat_process_directive and stamp the process root's repeat_for = Offset{ <q>, +1 } (= "once + q more"), reusing the existing ungated whole-chain repeat_for driver (repeated_full_chain, effects/mod.rs) — zero engine, zero new variant. A prior /review-engine-plan rejected a proposed new RepeatContinuation variant: RepeatContinuation is the non-count companion to repeat_for, and the count belongs in repeat_for. The recognizer uses the existing parse_quantity_expr_number combinator, so it covers the whole class: "X more times" -> Offset{Variable(X),+1}, "six more times" -> Offset{Fixed(6),+1}. Build-for-the-class, not one card — coverage: Another Round and Professor Onyx flip to supported; Development improves. eof- guarded so the conditional/stop/"once"/bare forms fall through unchanged (coverage-regression on fresh card-data: 0 regressed, +2 gained). CR 608.2c: the controller repeats the same instructions in order. CR 107.3a / 601.2b: X is announced at cast and fixed once. CR 400.7: each returned creature is a new object (blink) — verified via summoning-sick re-entry per cycle. Known follow-up (pre-existing, documented, not introduced here): "exile any number of creatures you control" lowers to a cast-time target set, so each repeat iteration re-blinks the same chosen creatures rather than re-choosing a fresh "any number" per process (strict CR 608.2c). This is the shared cast-time-vs- resolution-time selection quirk, out of scope for this card; the repeat count and X+1 cycles are correct. Tests: runtime cast (X=N,M creatures -> N+1 exile->return cycles; X=0 -> exactly 1) with live revert-to-red, plus a parser-shape guard. Parser-only. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(parser): reanimate self and a targeted graveyard card to the battlefield (CR 400.7) "Return this card and target land card from your graveyard to the battlefield tapped." (Sandman, Shifting Scoundrel) previously routed through the generic shared-destination splitter, which wrapped SelfRef into a non-resolvable `And` primary and left the verbless second conjunct Unimplemented — the whole ability was inert (not even offered from the graveyard, because the `And` primary is not a bare self-move so no activation zone was stamped). Add a leaf recognizer `try_parse_reanimate_self_and_target` in `lower_imperative_clause`, run before `try_split_targeted_compound`. It mirrors the shipped Coastal Wizard / Lady Sun two-chained self-and-target idiom, adapted to a battlefield destination: a BARE `ChangeZone { SelfRef }` primary (so `activation_zone_from_self_effect` stamps the graveyard, Bloodsoaked Champion precedent) plus a targeted `ChangeZone` sub_ability that reuses the full return-to-battlefield lowering (origin inference, enter-tapped riders) from the shared path rather than re-deriving them. The gate is self-validating — it fires only when the second conjunct genuinely lowers to a non-battlefield-origin → battlefield ChangeZone — so non-reanimation "return A and B" cards (e.g. Coastal Wizard's return-to-hand) fall through unchanged. Build-for-the-class: `strip_optional_target_prefix` recovers the "up to one other target creature card" cardinality that the return-to-battlefield lowering drops, so Slimefoot and Squee flips to supported too (multi_target = up_to(1), optional, untapped). Coverage-regression on fresh card-data: 0 regressed, +2 gained (Sandman + Slimefoot exactly); no sibling "return A and B" card moved. CR 400.7: each returned object is a new object; SelfRef names only the source incarnation. CR 608.2c: the two chained moves resolve in written order. CR 601.2c + CR 115.1: the graveyard card is a chosen target; SelfRef is not. CR 113.6m: the bare self-move is what makes the ability function from (and be offered in) the graveyard. CR 614.1: "to the battlefield tapped" enters both objects tapped. Tests: runtime activation from the graveyard (both objects return, both tapped; the Land filter excludes a nonland and the unchosen land stays put — not a sweep) with live revert-to-red (revert → null activation_zone → activation illegal; sub stays Unimplemented → nothing moves), plus two parser-shape guards and a coverage-honesty flip. Parser-only; no engine, no new variant. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(parser): disjunctive "first-of-type spell this turn" intervening-if (CR 603.4) Alania, Divergent Storm's trigger — "Whenever you cast a spell, if it's the first instant spell, the first sorcery spell, or the first Otter spell other than Alania you've cast this turn, you may have target opponent draw a card." — left the whole intervening-if plus the draw as an Unimplemented node. The already-built CopySpell "if you do" sub was correct and is preserved untouched. Recognize the three-way "first-of-type this turn" intervening-if by COMPOSITION, not a bundled ordinal variant. Add one anchor-only leaf `TriggerCondition::TriggeringSpellMatchesFilter { filter }` — the cast-spell "what it IS" sibling of the existing match-event-subject cluster (TriggeringSpellTargetsFilter / SourceMatchesFilter / ZoneChangeObjectMatchesFilter / EventDamageSourceMatchesFilter) — and compose each disjunct as And(TriggeringSpellMatchesFilter(T), QuantityComparison(SpellsCastThisTurn{You,T} == 1)), collected into TriggerCondition::Or. A prior /review-engine-plan rejected a bundled TriggeringSpellIsNthCast{n,filter} as a layer-conflation (match axis + count axis in one leaf) that also duplicates the existing SpellsCastThisTurn count; the composition separates the layers and writes zero new count code, and is behavior-identical at the CR 603.4 re-check. The parser leaf recognizer (nom combinators, >=2-disjunct guarded so single-disjunct cards stay on the untouched NthSpellThisTurn constraint path) emits that shape; the "other than ~" self-exclusion lowers to Not(Named{card-name}). Build-for-the-class: the anchor variant also unlocks the plain "if it's an instant spell" intervening-if. Also fix a latent bug this exposed: spell_record_matches_filter dropped TargetFilter::Named to the catch-all false, so Not(Named{X}) over spell history was always true — silently no-opping any name self-exclusion (Alania's "other than ~"). Add the Named arm (record.name == name). Measured: zero existing cards fed a top-level Named filter into a spell-record position, so this is a pure fix (coverage-regression on fresh card-data: 0 regressed, +1 gained = Alania only; the >=2-disjunct guard protected Vengevine and the 108 NthSpellThisTurn constraint cards). CR 603.4: the intervening-if is checked at trigger time AND resolution — the count is read live, so a second matching spell cast in response correctly fizzles it. CR 601.2a: the anchor keys on the SpellCast event's spell object. CR 201.2: card name for the self-exclusion. Tests: 6 runtime cast-pipeline tests (first-instant fires + copies; second-instant same turn does not; first-sorcery fires; Otter self-exclusion by name; the CR 603.4 in-response fizzle; optional-draw decline skips the copy) each with live revert-to-red, plus a parser-shape guard and a Vengevine constraint-path regression anchor. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Behold a [quality] as an interactive triggered-ability effect (CR 701.4a) Sarkhan, Dragon Ascendant's ETB "you may behold a Dragon. If you do, create a Treasure token." previously left an Unimplemented{behold} node — behold existed only as a casting cost (AbilityCost::Behold), never as an effect. The already- parsed Treasure sub (gated on OptionalEffectPerformed) and the second trigger (Dragon-enters → +1/+1 + becomes-Dragon-with-flying) are preserved untouched. Add Effect::Behold { filter }, an interactive resolution-time keyword action per CR 701.4a ("Reveal a [quality] card from your hand or choose a [quality] permanent you control on the battlefield"). The resolver (game/effects/behold.rs) reuses the single candidate authority eligible_behold_choices (battlefield-you- control ∪ matching hand) and a shared reveal_if_from_hand helper: - 0 candidates: whiff — set cost_payment_failed_flag, stash no continuation, so the "if you do" rider reads performed && !flag = false (no Treasure). - 1 candidate: forced, no agency — auto-select; a hand card emits CardsRevealed (card stays in hand), a permanent reveals nothing. - >=2 candidates: a genuine, rules-visible choice (CR 608.2d) — park a new WaitingFor::BeholdChoice for the controller; the submit handler validates the chosen object is in the candidate set, reveals-if-hand, sets the rider performed, and drains. A prior /review-engine-plan rejected an auto-resolve design: CR 701.4a grants the player the choice, and a hand reveal is a public event, so auto-picking among two or more distinct hand Dragons is an engine-made choice with rules-visible consequences — pillar #1 (rules-correct over convenient). Hidden-information correctness (CR 400.2): the BeholdChoice candidate list spans a hidden zone (hand), so visibility.rs redacts the pre-choice candidates to an opaque sentinel for opponents — otherwise the controller's matching hand cards would leak before they choose. The post-choice reveal exposes only the chosen card. Behold has no stack target (chosen at resolution, not declared): target_filter() is None, in the mass-effect group beside Clash/Populate. The frontend BeholdChoiceModal is display-only — it renders the engine-provided candidate list and dispatches a single-object SelectCards; it derives no eligibility. i18n reuses the existing cardChoice.behold.* keys (present in all seven locales from the cost-side UI) — zero new keys. #5051 (CR 400.7): this fixed-quality behold writes no ChosenAttribute, so the stale-chosen-type bug cannot manifest here; a code + test pin flags the future type_choice axis for the fix sweep. Tests: 7 runtime cast-pipeline tests (board-Dragon behold; hand-Dragon reveal stays in hand; >=2-hand-Dragon interactive prompt with only-chosen-revealed; opponent-view candidate redaction; accept-with-no-Dragon whiff; decline; parser shape) each with revert-to-red. Coverage-regression on fresh card-data: 0 regressed, +1 gained (Sarkhan). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): delayed sacrifice "at the end step on your next turn" (CR 513.2) Kav Landseeker's "When this creature enters, create a Lander token. At the beginning of the end step on your next turn, sacrifice that token." previously left the delayed clause an Unimplemented{at} node — the parser had no temporal arm for "the end step on your next turn", and the nearest existing condition (AtNextPhaseForPlayer, "your next end step") fires the CURRENT turn's end step (CR 513.2: a next-end-step delayed trigger created outside the end step is not backed up), which would sacrifice the Lander before it could be used. Add a turn-floor to AtNextPhaseForPlayer via a new typed enum TurnGate { None, AfterCreationTurn, After(u32) } (not a bool, not a magic sentinel): the parser emits the symbolic AfterCreationTurn, and effects::delayed_trigger::resolve stamps it to After(creation_turn) at creation (CR 603.7a) — the single-path binding site that already rewrites the PlayerId(0) controller placeholder. The matcher then skips every matching phase up to and including the floor turn and fires on the first strictly-later controller turn (CR 500.7 extra turns included). An unstamped AfterCreationTurn reaching the matcher debug_asserts and falls through to fire this turn (a loud, test-caught wrong-timing signal) rather than silently never firing. Existing AtNextPhaseForPlayer users (Greasefang, rebound, epic) default gate: None and are byte-identical — None is skip_serializing_if, so only Kav serializes a gate ("AfterCreationTurn", a pure semantic, no number). "That token" reuses the existing TargetFilter::LastCreated snapshot-at-creation (specific-token-safe per CR 603.7c / 400.7); the Lander predefined token and the parsed Lander creation are untouched. Tests: paired-timing runtime (Lander survives the current end step, is sacrificed at the controller's next end step) with a resolved-stack reach-guard, specific-token (a bystander token survives), a resolve-time stamp unit test, a parser-shape guard, and a Greasefang non-perturbation guard — each with revert-to-red. Coverage-regression on fresh card-data: 0 regressed, +1 gained (Kav). Singleton delayed-trigger test scope is deliberate (see #5072). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Crowd-Control Warden dual enters/turned-face-up counter replacement + suppress own as-enters on face-down entry (CR 708.2a) Crowd-Control Warden ("As this creature enters or is turned face up, put X +1/+1 counters on it, where X is the number of other creatures you control. Disguise …") previously left the replacement line an Unimplemented node — the replacement dispatcher matched the "As ~ enters" pattern but no inner parser handled the dual "enters OR is turned face up" condition + the "put … on it" clause surface. PARSER: recognize the self-and-face-up counters replacement (a new leaf `lower_as_enters_or_face_up_counters`, wired at the Priority-8 replacement slot). It emits two ReplacementDefinitions — one `Moved` (enter-the-battlefield) and one `TurnFaceUp` — sharing one `PutCounter{P1P1, ObjectCount(other creatures you control), SelfRef}` execute (CR 613.2a: both conditions generate the same effect). The dynamic-count "on it" anaphor lowers to ParentTarget; the recognizer normalizes it to SelfRef (matching the directly-built enters-with-counters shape) so the runtime folds it as an ETB event modifier. A guard requires every execute effect to be PutCounter{SelfRef}, so sibling "As ~ enters, choose…/becomes…" lines fall through untouched. The dynamic count + turned-face-up runtime were already fully supported (extract_etb_counters, turn_face_up_applier) — parser-only for the card. Coverage-regression on fresh card-data: 0 regressed, +1 gained (Crowd-Control Warden), blast radius exactly 1 card. RUNTIME (the parser fix exposed a class bug): playing the Warden face-down via Disguise wrongly gained the +1/+1 counters. CR 708.2a: a permanent that enters face down is a 2/2 with no text, so its OWN "As ~ enters" replacement must not apply on a face-down entry. Add a guard in object_replacement_candidate_applies: `if is_entering && ZoneChange{face_down_profile: Some} return false` — is_entering is true only when the candidate's source IS the entering object (its own text), so EXTERNAL replacements (another permanent's enters-tapped) still apply. Masked until now: existing morph/disguise cards with an own "enters with N counters" use an X count (=0 face-down); the Warden's ObjectCount is the first nonzero one to expose it. CR 708.3: the permanent is turned face down before it enters. A pre-existing change_zone test (paused_face_down_change_zone_resumes_face_down_with_profile) forced its replacement pause via the entrant's OWN SelfRef shock replacement on a face-down entry — inadvertently asserting the masked bug. Rework it (test-only) to force the pause via EXTERNAL replacements (mirroring the morph sibling), preserving the load-bearing seam assertion (the PendingChangeZoneIteration carrier preserves the face-down profile through pause/resume). Tests: parser-shape (dual replacement, zero Unimplemented) + as-enters-choose reach-guard; runtime face-down NEG discriminator (0 counters, reds to 2 on guard revert) + hard-cast POS + face-down-then-turn-up; a no-over-suppression external test + Hooded Hydra masked-class regression guards — each labeled by its discriminating role. Full test-engine: 17263 passed. Assisted-by: ClaudeCode:claude-opus-4.8 * chore(engine): rebase adaptation — classify S25 tranche variants into the #5072 walker The #5072 PR-6.75 read/write conflict profiler (ability_rw.rs) is exhaustive over Effect / TriggerCondition / TargetFilter / ControllerRef with no wildcards. This tranche, written before #5072 merged, added new variants/fields the walker had no arms for; rebasing onto main surfaced 13 compile errors (+ a latent Effect::Behold masked by same-match E0027s). Classify each with its read/write profile and the three ordering axes (reads_member_bound / reads_event_live / writes_event_object), CR-grounded and mirroring the closest existing sibling: - TargetFilter::GrantingObject (CR 201.5a): mirrors SpecificObject on the base axes; reads_member_bound = true (fail-closed R3 divergence). Parse-template-only — grant-clone concretizes it to SpecificObject{granter} (ability_utils.rs), so the arm is inert at runtime and classified conservatively. - ControllerRef::TargetOpponent (CR 109.4): mirrors TargetPlayer (runtime-read- identical) — all axes empty (declared-target read, member-invariant). - TriggerCondition::TriggeringSpellMatchesFilter (CR 601.2a/603.4): mirrors TriggeringSpellTargetsFilter — reads_event_live. - Effect::Behold (CR 701.4a/608.2d): mirrors Reveal — a pure information event, no write; its resolution-time choice is classified in ability_scan (MayPrompt). - Effect::Cloak.object_source (CR 701.58a), Effect::Dig.keep_count_expr (CR 701.20e/608.2c), Effect::GainActivatedAbilitiesOfTarget.scope (CR 602.1): new fields bound + profiled like their sibling fields. TurnGate.gate (a match-time firing gate, not traversed by ability_rw) and Effect::ControlNextTurn's ControlWindow (absorbed by the maximal-conservative arm) need no binding. Also adapts one lib test to a main API delta: #5072-era main replaced Effect::DealDamage's former `excess_only: bool` with a typed `excess` field, so a DealDamage constructor in copy_spell.rs's tests gains `excess: None` to match. One tail adaptation commit rather than folding into the owning card commits: unlike #5072's own case, the walker lives in the rebase BASE here, so folding WOULD restore per-commit compiles — but a six-way fold across the tranche is error-prone; the arms are grouped here and can be autosquashed at ship-prep if the final PR wants per-commit bisectability. cargo check --workspace is clean (engine + phase-ai + mtgish-import + server-core + wasm/server + test targets), zero downstream dual-walk sites, and the full engine test suite passes (17549 tests, 0 failures) — no HIGH-2 / C2-ungating trip in the tranche's tests (Kav's delayed-trigger tests were already singleton-scoped). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Rhys, the Evermore — interactive "remove any number of counters" Implements Rhys's "{W},{T}: Remove any number of counters from target creature you control" as a resolution-time interactive choice (CR 107.1c: "any number" includes zero; CR 608.2d: the controller chooses at resolution). Parser: a new "any number of" arm lowers the count to QuantityExpr::UpTo (Fixed{-1}), reusing the existing UpTo slot — no new variant, so the #5072 read/write dual-walk needs no arms. A CR 608.2d "from among" guard keeps out-of-scope multi-source removals (Galloping Lizrog, Eventide's Shadow) as Unimplemented rather than collapsing them to single-source semantics. Effect path: resolve_remove peels the UpTo flag and raises WaitingFor::RemoveCountersChoice carrying the target's live per-type counter counts; the controller's GameAction::ChooseCountersToRemove is validated by a validate_counter_selection helper shared with the cost path and applied through the CR 614.1 remove_counter_with_replacement pipeline via a pending_counter_removals queue. The queue re-parks on replacement choices and, on drain, stamps last_effect_count before the continuation drains, so "create that many" reflexive clauses (Tetravus) read the true removed total (CR 608.2h). Multiplayer: accepts_freeform_counter_removal + a session skip-legality arm let a human submit an intermediate per-type selection the coarse AI candidate set does not enumerate; the payload guard bounds the selection list. Coverage: GAINED = exactly {Rhys}. Tetravus's remove-counters trigger and token creation now resolve (proven: 3 counters -> 3 Tetravite tokens), but the card stays unsupported on an unrelated keyword-grant clause nested in that trigger. Cost-path storage lands / batteries and the multi-source cards are unchanged. The plan reviewer's re-review request was folded into /review-impl with a mandate to re-verify all six must-change resolutions at code level plus the four non-compile-enforced registration points; verdict APPROVE. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Esper Terra — "up to N" lore, conjunctive mana, token-anaphor counter binding Fixes the two parser-leaf gaps on Esper Terra (Terra, Magical Adept // Esper Terra) plus the shared binding that makes its marquee chapter effect land on the right object. No new engine variant (reuses QuantityExpr::UpTo, PutCounter, ManaProduction::Fixed, TargetFilter::LastCreated) — the #5072 read/write dual-walk needs no arms. Changes (all parser): - Gap A (counter.rs): "put up to three lore counters on it" — strip the "up to " count marker and wrap the count in QuantityExpr::up_to (CR 608.2d + CR 122.1), mirroring the Draw/Discard/PutSticker convention. Count-side only; the target-side "on up to N target(s)" path is untouched. - Gap B (mana.rs): "Add {W}{W}, {U}{U}, {B}{B}, {R}{R}, and {G}{G}" — a conjunctive comma+"and" fixed-mana-group accumulator (parse_fixed_mana_group_list) → ManaProduction::Fixed (CR 106.4). Rejects "or" lists, requires >=2 groups, no dedup. - §B2 token-anaphor bind (context.rs + mod.rs + counter.rs): a bare "put ... counters on it" following a token creator binds to the created token, not the ability source (CR 608.2c). A ParseContext.token_created_in_chain signal (set from the most-recent prior referent, cleared by an intervening typed target) drives the it-pronoun counter branch to TargetFilter::LastCreated. A companion LastCreated guard on replace_target_with_parent's counter arm (mod.rs) preserves that bind through the lowering pass — the guard its sibling Attach/UnattachAll arms already carried. The "if it's a Saga" gate needs NO code: it parses to TargetMatchesFilter{Saga} reading ability.targets.first() (the copied enchantment, CR 707.2 type-equal to the token), so a non-Saga copy places zero lore counters by construction. Coverage (full-DB regression): REGRESSED(engine)=0. GAINED=1 (Esper Terra). Eight counter-target fingerprint changes, all correct: - 4 anaphor target corrections (SelfRef→LastCreated): Esper Terra (x3 chapters, the +1 GAINED); applied geometry; the bus runner (first put; card stays unsupported on a separate gap); journey to the lost city. - 4 Gap-A parser wins (target unchanged SelfRef, a new "up to X" node parses): clockwork avian/beast/steed/swarm (stay unsupported on the counter-cap gap). - 9 genuine-source cards ("...on this creature/enchantment/<name>") byte-identical. Measured blast radius (replaces the plan's 6/7 prediction): 3 anaphor cards corrected (applied geometry, the bus runner, journey to the lost city) / 5 pre-existing-unchanged / 9 genuine-source preserved. The 5 unchanged cards (alien invasion, intrude on the mind, lasting fayth, match the odds, kianne) put counters via a dynamic "for each"-count PutCounter on a separate parser path that never reaches the it-branch — they stay SelfRef (misbound to source), byte-identical to baseline. That path is a pre-existing, LIVE rules-wrong gap this change does not touch; deferred and logged (.planning/coverage-analysis/S25-DEFERRALS.md D1). Ship-prep note: journey to the lost city appears in #5072's R3 member-bound classification; its PutCounter target change alters its ability AST, so its reads_member_bound rw-profile — and thus se_member_bound_class — may move +/-1 at the next full-DB sweep. Attributed to this commit (expected movement, not a regression). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): Foraging Wickermaw — "this creature becomes that color" Closes the last gap on Foraging Wickermaw: "{1}: Add one mana of any color. This creature becomes that color until end of turn." reuses the existing chosen-color carrier — no new engine variant (empty types/ diff). - Parser (oracle_effect/subject.rs): fold "that color" into the existing AddChosenColor arm of try_parse_become_color_modification, beside "the chosen color". "That color" is the color of the mana produced this activation — a resolution-time anaphor, not a value baked at parse (CR 106.1a/202.2/105.3). - Runtime (game/mana_abilities.rs): record the produced color as ChosenAttribute::Color on the source in produce_mana_from_ability, so the Layer-5 AddChosenColor reader (already powering Puca's Eye) sets the creature's color (CR 613.1e). Reuses the existing mana_sources::mana_type_to_color (Colorless→None) — no new converter, no types/mana.rs edit. Two safety properties, both proven by revert-to-red: - Gated write, zero blast radius. produce_mana_from_ability is the universal mana chokepoint, so the write fires only when the resolving ability's own chain carries a downstream AddChosenColor (via visit_links_any on the activated ability_def, fresh per activation). Coverage-regression confirms exactly one card flips (Foraging Wickermaw) and every ordinary producer — Birds of Paradise, City of Brass, painland, filter land, basic, rock — is byte-identical. - Explicit replace, not accumulate (CR 400.7). The stored ChosenAttribute::Color PERSISTS across turns (cleared only on zone change), UEOT expiry removes the Layer-5 effect but NOT the stored attribute, and the choice binder accumulates rather than replaces — so a plain push would leave a stale prior-turn color first in the list (chosen_color() is first-match). The write retain-drops the prior Color before pushing, so re-activating on a later turn with a new color replaces cleanly. The Colorless→White fallback converter (mana_payment.rs) is left untouched: it is load-bearing-defensive for the three Phyrexian-shard sites, where CR 107.4a makes Colorless unreachable. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): The Tomb of Aclazotz — cast-from-graveyard type-grant rider Closes the last gap on The Tomb of Aclazotz's "{T}: You may cast a creature spell from your graveyard this turn. If you do, it enters with a finality counter on it and is a Vampire in addition to its other types." The finality counter half was already wired via the ExileWithAltCost permission channel; this adds the parallel channel for the "is a Vampire in addition to its other types" type grant. - New Effect::AddPendingEntersModifications { modifications: Vec<ContinuousModification> } — the general "enters-with continuous modifications" carrier (CR 613 layer system), a categorical sibling of AddPendingETBCounters (CR 122 physical counters). The Vec<ContinuousModification> shape absorbs every future enters-with rider (added types, granted abilities, colors), so no third sibling is needed. The two channels stay separate because a counter is not a continuous modification. - Parser (oracle_effect/mod.rs): try_parse_cast_this_way_enters_rider lifts the former trailing-tail rejection (CR 205.1b) to accept "… and is a <type> in addition to its other types", parsing the tail via animation::parse_becomes_type_modifications (additive AddType/AddSubtype/ AddSupertype — retains printed types, CR 205.1b) and emitting the new effect as the counter rider's sub-ability. - Permission channel (mirrors enters_with_counter exactly): a new ExileWithAltCost.enters_with_modifications field (serde default + skip-if-empty), lifted from the rider at grant time and read back from the SELECTED permission at cast-finalize (CR 611.2c — no sibling-permission leak), applied to the cast object as a Duration::Permanent continuous effect (Layer 4, CR 613.1d). - #5072 read/write dual-walk: AddPendingEntersModifications is classified as a self-scoped SetMembership write (mirroring Effect::Animate/Endure's type-line writes) — NOT an ObjectCounters write; …
🤖 AI text below 🤖
Summary
PR-6.75 of the combo-detector series: C0-full + C1 precision pass — a typed read/write conflict-profile module (
ability_rw.rs) and a CR 603.3b same-event trigger-ordering gate that is fail-closed over the profiled read/write sets (sound modulo two documented profile-invisible symmetric residuals — §8-R4), replacing the shipped fail-open allowlist with a fail-closed classifier. 13 commits, engine-only — 7 original, 5 review-response commits (all review findings from the 2026-07-04 rounds, §8), 1 rebase-adaptation (v0.15.0 new-variant exhaustiveness):ability_rwread/write conflict profiler: per-ability typed read/write span extraction with a fail-closed dual-walk (every new enum variant/field must be explicitly classified per-axis or the build breaks — no wildcards).ControllerUniformitytyped group property ({Mixed, Uniform, UniformAligned}; CR 805.7 team pools are the sole mixer; 27→24).b4ec14aa9) — same-event member-bound discriminator (review finding 1; §8).5f109132b) — non-phase delayed triggers routed throughbegin_trigger_ordering(review finding 2; §8).41843f974) — v0.15.0 new-variant exhaustiveness classification for the dual-walk (§8).53838b08b) — same-event event-object read/write feed closed (review round-4 finding; §8-R4).83bb4084a) — production-pathgroup_is_order_independentpin for the R4 discriminator (review round-5 finding; §8-R5).01580f3bf) — adopts perf(engine): batch identical self-counter triggers #5084's loop-detection ungating of C2 finite ordering and closes the Dodecapod under-prompt the ungating surfaced (§8-R5).Files changed
crates/engine/src/game/ability_rw.rs(new module, +7448)crates/engine/src/game/ability_scan.rs(+17)crates/engine/src/game/engine_priority.rs(+9)crates/engine/src/game/mod.rs(+1)crates/engine/src/game/triggers.rs(+414/−189)crates/engine/src/game/triggers_ordering_parity_tests.rs(new test module, +2138)crates/engine/tests/integration/daretti_emblem_simultaneous_death.rs(+11/−8 — updated to the newOrderTriggerscontract, §8)Track
Developer
LLM
Model: claude-opus-4-8
Thinking: high
Tier: Frontier
Implementation method (required)
/engine-implementerpipeline (plan → review-plan → implement → review-impl → commit)Every commit in the series ran the full pipeline: signed-off plan (
PR-6.75-C0FULL-C1-PLAN.md, 4-round plan review R1→R4-clean), per-commit implementer, per-commit adversarial/review-impl(each round's REQUIRED fixes applied before commit), plus one final cumulative adversarial review over the whole 7-commit diff (verdict: SHIP).CR references
69 unique CR annotations in the series diff, every one grep-verified against
docs/MagicCompRules.txtbefore writing. Load-bearing: CR 603.3b (APNAP trigger ordering — the seam this PR fixes), CR 603.4 (intervening-if re-check), CR 603.10a (delayed/legacy refs), CR 805.7 (team trigger pools = the sole controller-mixer), CR 701.21a (sacrifice → owner's graveyard, the osseous/skyfisher class), CR 303.4d + CR 301.5c (Aura/Equipment can't attach to itself — §L6), CR 120.4a (excess damage → PlayerLife write), CR 608.2h, CR 704.5a/g/j, CR 904.3. One wrong pre-existing citation found by the cumulative review was fixed in-series (§L6: 701.3d/704.5j → 303.4d/301.5c).Verification
Developer-track checklist for this head (01580f3, rebased onto
upstream/maincf7cd7c):cargo fmt --all— cleancargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings— clean, 0 lints (re-run at 01580f3)./scripts/check-parser-combinators.sh— clean (no parser files in the diff)cargo test -p engine --features proptest— 0 failures at 01580f3 (engine lib 15103 pass / 0 fail + all integration binaries pass)client/public/card-data-meta.json); 0 new Unimplemented entries (this PR adds no parser surface)FORGE_TEST_FULL_DB=1 cargo test -p engine ordering_parity_sweep— GREEN: 0 unexplained at the rebased head (details below)Commands run at this head (01580f3) — three rebases during review-response, each verified by range-diff: onto 1badc19 (9/9
=modulo one benignuse-line 3-way with #4625) + one adaptation commit (§8); onto 4c0ac54 (11/11=, pure base-move); onto cf7cd7c (conflict resolution with #5084 — 10/12=; commit-2!= the C1 conflict resolution keeps the commit's own gated form, re-applied as the tail ungating commit so HEAD adopts #5084; HIGH-2!= context-line only from #5084's visibility bump; §8-R5). No dual-walk exhaustiveness breaks in any rebase after the adaptation commit (#5090 adds no enum variant):FORGE_TEST_FULL_DB=1 cargo test -p engine ordering_parity_sweep(corpus regenerated at 01580f3, incl. #5080/#5090 parser changes)over_prompt_hit=18,batch_genuine_hit=1(Day of the Dragons),cat1_hit=6,se_mb_genuine=6(§8 exact-set assert),se_member_bound_class=43(predicate-derived, floor ≥35),se_event_object_class=13+SAME_EVENT_EVENT_OBJECT_GENUINE={}(§8-R4, empty exact-set asserted, floor ≥9, disjointness vs member-bound asserted); all floors pass,t1_source_indep=2820(assert ≥2727). Zero movement across all three rebases — both upstream parser changes measured corpus-neutral; the C2-ungating is off the sweep'sprofiles_conflictpath (predicted AND measured)se_member_bound_class43→0 and trips the floor + the genuine exact-set assertcargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warningscargo test -p engine --features proptestcargo fmt --all/ parser-combinator gate vs true baseBehavioral trace of the trigger-ordering seam:
begin_trigger_orderingcollects the same-event batch → partitions by controller (CR 805.7 team pools are the only path that mixes controllers;condition1_dodecapod_mixed_controller_still_promptspins that mixed groups always prompt) →group_is_order_independentconsults theability_rwread/write profiles → any read/write span conflict ⇒ prompt (player orders, CR 603.3b); provably-disjoint spans ⇒ auto-order. Monotone safety: the pre-C1 path unconditionally auto-ordered same-event groups, so C1 can only ADD prompts — it cannot remove one.Under-prompt proof (the #1 risk): the sweep's direction gate flags
decision_old && !decision_new(previously-prompted → now-auto) rows intounexplained, which is asserted empty — an allowlist entry cannot hide an under-prompt by construction. The 7 newly-prompting category-1 cards are each measured genuinely order-dependent (table below); the 18 conservative over-prompts are documented with per-card reasons and completeness-asserted at full-DB. Discriminating runtime pins:b2_dotd_member_bound_pin,b1_mindslicer_uniformity_gate, the Dodecapod executable negative, freeze-auto discriminators N-A/N-B, and a mechanical 12-tag×position exhaustiveness matrix for the D5 visitor.Rules correctness (CR 603.3b)
Per the 2026-07-03 maintainer/user ruling: C1 ships UNGATED — it is a bug fix on the always-on legacy ordering path (the gated feature remains C2 from #4904/#4603). Rationale: rules correctness is the project's primary concern; the pre-C1 path could silently auto-order genuinely order-dependent same-event triggers (under-prompt = rules-wrong). C1 is a strict tightening: the direction gate in the parity sweep (
decision_old && !decision_new) proves no card auto-orders under C1 that prompted before, and the 6 newly-prompting cards (+1 parse-blocked, table below) are measured genuinely order-dependent.§7 Full-DB parity sweep: final accounting (sweep GREEN)
Honesty correction: step-4 found 0 genuine at SAME-EVENT depth; commit-6's deeper BATCH analysis reclassified Day of the Dragons as genuinely order-dependent. The earlier "0 genuine among the 64" statement was correct for the same-event analysis it was made under and is superseded — not silently revised — by the batch-depth result. D3's zero-widening premise did not hold at full-corpus scale.
Arithmetic: the pre-allowlist full-DB sweep panicked at exactly 19 unexplained rows: 19 = 18 documented-conservative (17 same-event + 1 batch [Skyfisher Spider]) + 1 GENUINE (Day of the Dragons, batch). Skyfisher Spider and Day of the Dragons are both batch-arm diffs but land in DIFFERENT categories: skyfisher's prompt is conservative (owner-misalignment / osseous-class — its owner-ALIGNED co-departure is genuinely auto-safe via sum-symmetry [both the total 2N−1 and the per-copy multiset {1..N} are order-invariant, so even an intermediate "whenever you gain life" observer is order-blind], while the owner-MISaligned case IS order-observable but is correctly prompted by production — see the ledger), DotD's prompt is correct (order genuinely observable even when fully aligned). After allowlisting, the sweep reports 0 unexplained and is GREEN.
Measured at the pre-fold series tip (
FORGE_TEST_FULL_DB=1):swept=13543 compared=6206; the four surviving floor metrics plus the removedsrcread's measurement{srcread=0, obs=494, hadcounters=1, retained=285, t1=2871}. Re-measured after rebase onto current main with a corpus regenerated at the rebased tip (provenance:client/public/card-data-meta.json, generator-stamped): zero drift on every gated metric (swept+4 benign corpus growth,t1−1 with 143 floor margin).Intended prompts (category 1) — same-event genuine (6 measured flips + 1 parse-blocked) + batch genuine (1):
Power{Source}read is fed by the other'sPutCounterAllObjectCount{Permanent}intervening-if read × self-Bounce census overlapAnyso the sweep never forms its group (expected-UNHIT, asserted)Sacrifice{Dragons, count: ObjectCount}→ChangeZone{TrackedSet(0) → battlefield}: each member returns its OWN tracked set and the returned creatures can be Dragons the other member's re-sacrifice consumes (CR 603.3b) — pinned at runtime byb2_dotd_member_bound_pinDocumented-conservative over-prompt ledger (18) — mirrors
DOCUMENTED_OVER_PROMPTintriggers_ordering_parity_tests.rs(the source of truth; every entry completeness-asserted at full-DB):Any).Floors: re-tuned to full-DB measured values minus 5% corpus-churn tolerance (
batch_obs 494 → ≥469,retained_prompt 285 → ≥271,t1_source_indep 2871 → ≥2727,hadcounters_batch_self ≥1witnessed by Promising Duskmage);batch_self_srcreadREMOVED — its cell is empty in-corpus (its old ≥40 population was an artifact of the D5 fail-open hole that commit 3 closed; that freeze-auto class is now counted byretained_prompt, and the freeze-auto behavior is pinned by unit discriminators N-A/N-B). Every surviving floor and every ledger entry is mutation-proven non-vacuous (review probe matrix M1–M9: each mutation flips one classifier conjunct → full-DB sweep → the named floor trips or the named assert fails; M4 additionally proved the direction gate — 50 under-prompt rows surfaced, 0 suppressible).§8 Review-response (maintainer CHANGES_REQUESTED, 2026-07-04) — two HIGH findings closed + rebase onto v0.15.0
Two [HIGH] maintainer findings were addressed as new commits, then the whole 9-commit series was rebased onto the current
upstream/main(v0.15.0+,1badc1920). The full-DB parity sweep was regenerated and re-run at the rebased head and is GREEN (0 unexplained).HIGH-1 — same-event member-bound trigger-ordering discriminator (commit
b4ec14aa9, CR 603.3b / 603.10a)profiles_conflict's same-event fast path auto-ordered anysource_independentgroup, but a group of DISTINCT sources whose identical resolution reads per-source bound storage (CR 603.10a look-back —TrackedSet/ExiledBySource/ChosenCard, viamember_bound_target_filter) is NOT one sharedf(state): member A reads A's storage, member B reads B's, so the identical-function commutation proof breaks and the order can be observable. Since same-event order-independence is decided solely by!same_event_conflict(noc2backstop), this auto-ordered genuinely order-dependent groups. Fix mirrors the batch path's!reads_member_boundconjunct at same-event depth: exclude member-bound from the fast-path disjunct + add the fail-closed discriminatorif s.same_event && p.reads_member_bound { return true }.all_same_sourcestays auto-ordered (shared storage ⇒f_A = f_B); the batch path is byte-inert.NOT latent — the sweep caught it. The fix flips 48 same-event cards auto→prompt (all over-prompt direction — the base engine auto-ordered every same-event group unconditionally, so these can never be under-prompts). Split:
SAME_EVENT_MEMBER_BOUND_GENUINE, exact-set asserted): mimic vat, mirror of life trapping (imprint-contention — two copies race to imprint the ONE shared triggering creature into their per-source pile, CR 603.10a); moonring mirror, duplicity (hand-swap re-capture — each copy exiles the shared hand into its own pile then returns its prior pile, A feeds B); world queller (shared-pool consumption under per-copy-divergent chosen types, CR 701.21 — genuine on rules grounds; itsreads_member_boundbit comes from a non-obvious AST carrier, not the surfaceIsChosenCreatureType); blood tyrant (genuine via a DIFFERENT axis — aPutCounter{SelfRef}self-write whose per-copy split is order-observable when a player hits 0 life between resolutions, CR 704.5a/800.4a; caught incidentally by the member-bound discriminator).se_member_bound_class, keyedsame_event ∧ reads_member_bound ∧ auto→prompt— the discriminator's own gate), NOT a per-card allowlist: membership is DERIVED + emitted as an evidence artifact, so it is robust to corpus churn. Measured 43 at the rebased corpus (was 42 pre-rebase; the +1 is ichormoon gauntlet, aChooseCounterKind/PutChosenCountercard the v0.15.0 parser newly surfaced — a conservative counter-add, order-immaterial). A predicate-keyed class was chosen precisely so a corpus-churn member-bound card auto-absorbs without a manual allowlist edit; a 48-name allowlist would have broken the completeness assert at exactly this rebase.Class floor
se_member_bound_class ≥ 35(mutation-proof: deleting the discriminator collapses it to 0).t1_source_indepmeasured 2871→2830→2831 (the source-independent slice of the class now prompts instead of auto-ordering; assert ≥2727 unchanged, still passes — an accounted movement, not a floor bump).HIGH-2 — non-phase delayed triggers routed through the CR 603.3b ordering gate (commit
5f109132b, CR 603.3b / 603.7)check_delayed_triggers(CR 603.7) APNAP-sorted its firing batch then DIRECTLY dispatched, bypassingbegin_trigger_ordering— so two+ simultaneous same-controller order-dependent non-phase delayed triggered abilities reached the stack in fixed order with no CR 603.3b ordering choice. Pre-existing (byte-identical base↔HEAD); this PR madebegin_trigger_orderingthe sound authority but never wired this path. Fix routes theto_firebatch throughbegin_trigger_ordering(the phase-delayed path is the template), preserving every delayed-specific disposition (Good King Mog, Breeches reflexive pause, epic, multi-fire) via the sharedDelayed-origin dispatcher, plus oneengine_priorityguard to surface theOrderTriggersprompt. Pure tightening: singletons and provably-commuting identical no-input groups still auto-order. One integration test (daretti_emblem_simultaneous_death) asserted the old direct-dispatch contract for two distinct-target simultaneous returns and is updated to a positiveOrderTriggerspin.Issue #4809 (Braids/Spinal Embrace) is a DISTINCT phase-path seam, not this bug —
AtNextPhase{End}is phase-classified and already merged+ordered by the phase path (proven by the existing #5048 test); HIGH-2 neither closes nor regresses it. (Separate empirical follow-up.)Rebase onto v0.15.0 — new-variant exhaustiveness (single adaptation commit
41843f974)The rebase surfaced 6 new upstream enum variants in the wildcard-free
ability_rwdual-walk, all classified per rw-axis with grep-verified CRs (full table in the adaptation commit body):FilterProp::InTrackedSet(member-bound — chain tracked-set membership),Effect::EachSourceDealsDamage(CR 120),ChooseCounterKind/PutChosenCounter(member-bound, per-source chosen counter, CR 122.1/122.6),CreatePlaneswalkReplacement(deferred body),ChaosEnsues(CR 311.7). Corpus regenerated at the rebased head; sweep re-run GREEN.Convention note (vs the pre-PR rebase): the earlier pre-PR rebase folded its 15 new-variant arms into their owning profiler commits (per-commit-compiles). This rebase's arms could NOT be folded that way — three of them depend on engine features born LATER in the series (
CreatePlaneswalkReplacementusespscope/chain_move_owner/the 4-argrw_effectsignature from the same_controller commit;ChooseCounterKind/PutChosenCountersetreads_member_boundfrom the batch-hard commit), so folding would require deliberately-wrong evolving stub arms across three commits. They ship as one honest adaptation commit instead. Per-commit-compiles for c1–c8 was already forfeited by the rebase itself (non-exhaustive vs the new base by construction); the sound bisect build points are the base and HEAD. This is measured necessity, not a convention lapse.Corrected arithmetic at the rebased corpus (
b76c53274962cbf0): the same-event member-bound bucket = 43 conservative + 6 genuine; the original batch/same-event ledger (18 over-prompt + 6 cat-1 + 1 DotD genuine) is unchanged. Sweep:swept=13556 compared=6208 unexplained=0.§8-R4 Review-response (round 4) — same-event event-object feed CLOSED
The round-4 [HIGH] — the same-event gate was not fail-closed for the event-object read/write feed — was accepted (option 1: close the feed) and fixed as commit
53838b08b, then the full series was rebased onto4c0ac54f5.The gap:
read_object_scopemapsEventSource/EventTargetreads toreads_event_live, a bare bool with no KindSet, so thefeeds()kind×kind matrix was structurally blind to awrites_event_objectmutation feeding an event-object live read (CR 608.2h — the read uses the object's current information). Same-event members share ONE live event object, so a member's write is observed by a sibling's live read ⇒ order-observable. The batch path was NOT holed (asymmetry, not a symmetric miss): a co-departure batch event object has DEPARTED and is frozen LKI (CR 603.10a) — a write no-ops or trips the freeze-invalidation row.The fix (CR 603.3b / 608.2h): new shared predicate
reads_and_writes_event_object() = reads_event_live && writes_event_object.any()— a conjunction, derived not copied (the batch guard is a disjunction-of-negations because refusing a fast path only defers; a prompt-returning discriminator must witness a real feed = both endpoints). Same-event T1 fast-pathsource_independentbranch gains the exclusion; new discriminatorif s.same_event && s.event_object_present && reads_and_writes_event_object() { return true }after the member-bound row. Gated onevent_object_present(mirrorseffective_external: a write to a non-present event object no-ops per targeting.rs, so Phase-mode triggers stay auto — the gate can only drop vacuous prompts, zero under-prompt risk).all_same_sourcestays auto (identical f over one shared object); both editss.same_event-guarded ⇒ batch byte-inert.Sweep: +13 same-event flips, all over-prompt direction, 0 GENUINE (
SAME_EVENT_EVENT_OBJECT_GENUINEships EMPTY, exact-set asserted;se_event_object_class=13, predicate-keyed, membership derived + emitted, floor ≥9 mutation-proof, disjointness vsse_member_bound_classasserted). Governing theorem (why R4 has 0 genuine where R3 had 6): every R4 write targets the SHARED event object — k identical fs on one shared object compose to the same final state under every permutation (relabel-invariant) — while R3's genuine members wrote to PER-SOURCE storage (imprint pile A ≠ pile B), an order-divergent destination. The 13: 7 frozen-context reads (EventContextAmount/EventOutcomeWon— a write cannot feed a frozen amount), idempotent shared-object writes (untap/exile/destroy, CR 701.26a/26b/608.2b), additive-commutative counters. Per-card both-orders traces in the classification artifact.sidequest: catch a fishsits OUTSIDE the class by theevent_object_presentconjunct (Phase-mode, no live event object, write provably no-ops) — excluded, not missed.Claims-consistency (the R4 failure mode was a doc-vs-code gap): the module doc now records the direct event-object feed as CLOSED at both depths, and enumerates the two residuals that REMAIN — the source-actor granted-state channel and a board-wide
writes_external× event-live-read channel — both SYMMETRIC across batch and same-event depths. Thegroup_is_order_independentcontract comment now states exactly this: never auto-orders order-dependence visible in the profiled read/write sets; sound modulo two profile-invisible symmetric residuals.Second rebase (onto
4c0ac54f5): pure base-move, all 11 commits=in the range-diff; no new enum variants ⇒ no dual-walk breaks. #5088 (World-rule SBA, CR 704.5k) implements the rule cited for winter's night's practical unreachability — explicitly re-verified: the sweep uses structural reachability, winter's night remains a class member. Sweep at the fresh corpus: zero movement on every count vs pre-rebase.§8-R5 Review-response (round 5) — caller-level regression test + C2-ungating adoption
R5-1 (the round-5 [HIGH], commit
83bb4084a, test-only): a production-path regression test now pins the R4 discriminator at thegroup_is_order_independentCALLER level (whereevent_object_presentis threaded from the real pending trigger event) — distinct sources on one shared event object with anEventSource/EventTargetlive read + aTriggeringSourceevent-object write ⇒ NOT order-independent; guard cases (read-only ⇒ auto, write-only/no-event-object ⇒ auto, all-same-source ⇒ auto) prove the discriminator rather than a blanket prompt. Revert-to-red measured at the caller level: disabling the discriminator reds the positive case.R5-2 (commit
01580f3bf): adopting upstream #5084's C2-ungating surfaced a LATENT SERIES BUG, now fixed. #5084 removed the loop-detection gate from the C2 finite-ordering term (maintainer rationale: loop detection governs infinite-combo shortcutting, not finite CR 603.3b ordering UX). Applying it verbatim turned two of this series' own discriminating NEGs red (condition1_dodecapod_mixed_controller_still_prompts,b1_mindslicer_uniformity_gate): the coarseability_scan-computed C2 term is structurally blind to the source-actor residual, and as a disjunct it could OVERRIDE the precise batch/uniformity prompt for a Mixed-controller co-departure — a genuine under-prompt (the Dodecapod cause-source-controller channel). This was latent in the series all along (reachable only under loop-ON, which the NEGs never exercised); the ungating made it default-path. Fix: the C2 term becomesc2_order_independent && !batch_conflict— a precision-dominance rule (the coarse walker may auto-order only when the precise profiler agrees). This is NOT a revert of #5084: the loop-ungating stands, and #5084's ownpr625_c2fan-out behavior is preserved (its group hasbatch_conflict=false); review-impl confirmed the conjunct is sound everywhere (a real co-departure batch never reaches the C2 term; for non-co-departure groups the conjunct is strictly more conservative). Revert-to-red: the barec2form re-reds both NEGs; the conjunct form is 5/5 green.Third rebase (onto
cf7cd7cb9, resolving the #5084 conflict): range-diff 10/12=; commit-2!= the C1 conflict resolution retains that commit's own gated form with the ungating applied at the tail commit (HEAD adopts #5084); HIGH-2!= context-line only (visibility bump). #5084's stack.rs self-counter batching was classified orthogonal to the gate (resolution-time, same-source, checkpoint-proven sequential-equivalent — triggers are already ordered before they reach the stack). #5090 adds no enum variant; corpus regenerated; sweep zero-movement (table above).Review summary
unexplained); ~50 CR annotations grep-verified in review; one CR-citation nit fixed in-series (§L6 now cites CR 303.4d "an Aura can't enchant itself" + CR 301.5c "an Equipment can't equip itself").Combo-detector series
ResourceVector+ modulo-resource loop equality (additive).ResourceVector.detect_loop→LoopCertificate+ corpus harness.cargo combo-verifyCLI over the 53-row corpus.∞unbounded-resource display — generalize infinite-mana to the wholeResourceAxisclass (engine-ownedDerivedViewsprojection).group_is_order_independent(latent CR 603.3b). Notes:PR-6.25-DEFERRED-FINDINGS.md.PR-6.5-EPIC-GROWING-CASCADE.md.ability_rwread/write conflict-profile module, latent CR 603.3b same-event fix, ungated per maintainer ruling.Predecessor: PR-6.5 — #4904 — #4904 — delivered the C0 fail-closed walker skeleton and the C2 gated auto-resolve; this PR completes C0 to full read/write profiling and ships C1, turning the walker's classifications into the sound same-event ordering gate.
Related: #4603 (C2 gating policy), #4990 (S07 series whose
subject_slotfield is classified member-bound here).Scope Expansion
None.
Validation Failures
None.
CI Failures
None.
🤖 Generated with Claude Code