Skip to content

fix(engine): record a token battlefield entry even when its events are suppressed (CR 403.3) - #6851

Merged
matthewevans merged 7 commits into
phase-rs:mainfrom
lgray:fix/cr403-suppressed-token-entry-record
Aug 2, 2026
Merged

fix(engine): record a token battlefield entry even when its events are suppressed (CR 403.3)#6851
matthewevans merged 7 commits into
phase-rs:mainfrom
lgray:fix/cr403-suppressed-token-entry-record

Conversation

@lgray

@lgray lgray commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

Replaces the split record-here / refresh-there route for CR 403.3 token battlefield entries with
one authoritative, resume-safe record/emit lifecycle, per @matthewevans' review. The Suppress
finalize tail now records nothing and parks the entry on GameState; a single consumer takes that
one owned value and performs record_zone_change (the sole writer of both CR 400.7 ledgers) and the
CR 603.6a event pair together, so a stale row, a lost emit, and CodeRabbit's duplicate
battlefield-entry row are all unrepresentable rather than guarded.

Round 3 closes the reachable trigger defect from @matthewevans' second review. The direct-return
reducer arms — handle_tribute_choice and 15 siblings — build their ActionResult inside the match
and never reach apply_action's tail pipeline, so a copy token realized on those routes previously
entered with no ETB observer ever seeing it. apply_action_boundary_core now converges them through
the same run_post_action_pipeline_from the tail uses. A second commit makes the CI parse-diff
sticky identify the head it was generated from.

Files changed

Trigger convergence (CR 603.6a)

  • crates/engine/src/game/engine.rsapply_action_boundary_core runs the normal post-action
    pipeline when the settled gate realized an entry, over the slice the realization appended
  • crates/engine/src/game/effects/token.rsrealize_settled_token_battlefield_entry returns
    whether it realized; the park, flush_pending_token_battlefield_entry (sole consumer)
  • crates/engine/src/types/game_state.rsPendingTokenBattlefieldEntry + the parked
    GameState::pending_token_battlefield_entry; EmitCommittedCopyTokenEntry slimmed to { object_id }
  • crates/engine/src/game/engine_replacement.rs — the unpaused convergence point
  • crates/engine/src/game/effects/counters.rs — the CR 616.1 counter-drain convergence point
  • crates/engine/src/game/elimination.rs, crates/engine/src/game/scenario_db.rs,
    crates/engine/src/game/turns.rs — abandonment and per-turn clears
  • crates/engine/tests/integration/token_zone_change_index.rs — production regressions
  • crates/engine/tests/fixtures/cr733/authority_matrix.json.gz — the new field's write-site entry

Parse-diff head binding

  • crates/engine/src/bin/coverage_parse_diff.rsrender_markdown emits the head SHA on both the
    changed and unchanged branches; --base-sha/--head-sha reject a missing value; parse-diff.json
    carries both SHAs

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer

Full pipeline, every step in a fresh context. Round 3 (this review cycle): plan → 2 independent
/review-engine-plan rounds → implement → /review-impl → comment-correction pass. Plan/review
agents ran at xhigh, implementation agents at high. Round-3 plan review 1 returned 6 findings
(0 blocking); round 2 returned 5 (0 blocking); impl review returned 3 LOW. Every finding was fixed
with code and re-measured. A follow-up commit addresses two CodeRabbit findings on the parse-diff
provenance (below). Earlier rounds are recorded in the previous revision of this body.

Reviewers re-measured rather than trusting reports, and that caught real defects in the work: plan
review corrected three source citations that the previous round's reviewer had signed off as correct
(engine.rs:7355-7363 is the ExploreChoice arm, not the CopyTargetChoice one), and impl review
rejected the executor's stated proof that the new bool was load-bearing as a non-sequitur — the
probes it cited flip pre-existing assertions — then ran a one-bit discriminator that establishes it
properly.

CR references

CR 400.7 (the per-turn zone-change ledger) · CR 403.3 (battlefield-entry bookkeeping) ·
CR 514.2 (the cleanup step ends "this turn" state — the authority for the per-turn reset) ·
CR 603.2 / CR 603.2c (an ability triggers once per occurrence; turn_zone_change_index is the
engine's own key) · CR 603.3b (two same-controller triggers are ordered by their controller — why
the new regression asserts an ordering prompt rather than papering over one) · CR 603.4 (Fanatic's
intervening-if) · CR 603.6a (ETB abilities are checked whenever an event puts a permanent onto the
battlefield — the rule the direct-return arms were violating) · CR 614.12a (the as-enters choice is
made before the permanent enters) · CR 616.1 (competing replacement ordering) · CR 702.104a /
CR 702.104b (Tribute, and "if tribute wasn't paid") · CR 704.3 / CR 704.4 (SBA timing) ·
CR 704.5f (toughness ≤ 0, the departed-token branch) · CR 800.4a (a player leaves the game).
All grepped in docs/MagicCompRules.txt before use and independently re-verified at the exact line
numbers by the impl reviewer; zero UNVERIFIED.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • cargo fmt --all — no drift

  • cargo check --workspace --all-targets — exit 0

  • cargo clippy --workspace --all-targets -- -D warnings — exit 0, 0 warnings

  • cargo test -p phase-engine --no-fail-fast18268 + 12 + 9 + 4374 + 0 = 22663 passed / 0 failed (6+2+7 ignored)

  • cargo test -p phase-ai2065 passed / 0 failed

  • Parser combinator gate — N/A, no crates/engine/src/parser/ file in the diff (Gate A run anyway, below)

The reachable trigger defect, measured on the path @matthewevans named
(CopyTargetChoice → NamedChoice → TributeChoice, Soul Warden on the battlefield):

Soul Warden delta CR 400.7 ledgers emitted pair
previous head bee984f80 0 correct correct
this head 1 correct correct

The +1 is produced by engine_priority::run_post_action_pipeline_from at the action boundary — the
same function run_post_action_pipeline itself delegates to — over the slice the realization
appended, not by an alternate route. Verified by reverting the new block: the regression fails at its
reach-guard first (left: […"TributeChoice"] right: […"TributeChoice","OrderTriggers(2)"]), and with
the guard relaxed so execution proceeds, the life assertion alone fails left: 0, right: 1 — exactly
the value the review called out. The fix is arm-agnostic: the reducer match has 16 non-error direct
returns
and the convergence is inert on the 15 that never park.

The regression also pins "OrderTriggers(2)". The (2) is the discriminating part — Tribute is
declined, so Fanatic's own CR 702.104b intervening-if is true and its ETB fires alongside Soul
Warden's; both are P0's, so CR 603.3b requires an ordering prompt. The sibling routes (Painter,
Watchdog) keep full-vector prompt assertions with no OrderTriggers element, so a spurious ordering
prompt on those routes fails them.

Revert probes, each actually run and restored byte-identically (md5-verified), including four run
independently by the reviewer:

  • delete the boundary pipeline block, keep the bare realize ⇒ reach-guard flips, 10/11 green
  • same, with the guard relaxed ⇒ the life assertion alone flips 0 → 1
  • delete both in-action realization points ⇒ 11/11 green (they are now redundant for triggers; they
    are retained for CR 704.3 SBA ordering and for a counter drain that does not settle in its own action)
  • flip the CR 704.5f drop branch's returned false ⇒ fails at exactly the arm-iii assertion, nothing else
  • scan_from → 0 ⇒ nothing flips (see the disclosure below)
  • delete the unpaused-route flush ⇒ left: (0, 0) right: (1, 1)
  • replace the park with an immediate record ⇒ 6 unit + 5 integration flip; the Emit positive control stays green
  • change-set B: delete the head writeln! ⇒ both contains(head) assertions flip; move it above the
    marker ⇒ the marker-first assertion flips

Save/load compatibility for the GameState field — measured, not assumed. GameState carries no
deny_unknown_fields, and the field is #[serde(default, skip_serializing_if = "Option::is_none")],
so an absent key loads as None and a None value is never written back. Census: the repo ships
11 real {"gameState": …} dumps as .json.gz fixtures, all predating this field;
pending_token_battlefield_entry occurs 0 times in every one of them. Every test that
deserializes one of those dumps passes: 132 passed / 0 failed, plus a serde round-trip unit test.
No compatibility shim, no pristine-dump migration.

Rebased onto upstream/main 36ac6d2a1 before pushing. That range touches engine.rs, which
this PR modifies, so the full gate set above was re-run after the rebase rather than reused: the
rebase itself was conflict-free, and the post-rebase numbers are the ones reported here (the counts
rose from the pre-rebase run because upstream added tests).

Gate A

Gate A PASS head=bc9f9445524f90c16cbbe27ae720d644fe4da723 base=36ac6d2a1ccb4c891a20403557dfe5ac296a7022

Anchored on

  • crates/engine/src/game/engine_payment_choices.rs:1249 — run_post_action_pipeline_from at a
    nested-slice seam; it has five existing production call sites for exactly this problem
    (engine_payment_choices.rs:1249/1317/1339, stack.rs:2647, engine_resolution_choices.rs:543),
    and the new boundary call is the same shape. The state.waiting_for = wf.clone(); result.waiting_for = wf;
    pair mirrors apply_action's own post-pipeline sync verbatim — both writes are required, for two
    different reasons: finish_action_boundarysync_waiting_for copies result into state, so a
    state-only write is undone, and the life-safety preview never calls finish_action_boundary, so a
    result-only write is insufficient
  • crates/engine/src/types/game_state.rs:12634 — pending_liminal_entry_resume, the existing
    Option<…> mid-resolution park on GameState with #[serde(default, skip_serializing_if = "Option::is_none")];
    pending_token_battlefield_entry mirrors it field attribute for field attribute

Final review-impl

Final review-impl PASS head=bc9f9445524f90c16cbbe27ae720d644fe4da723

Claimed parse impact

None.

Scope Expansion

One, requested by the reviewer as required evidence. The <!-- coverage-parse-diff --> sticky
could not be made to identify the head by regenerating it, because no generator branch had ever
emitted a head SHA. render_markdown (crates/engine/src/bin/coverage_parse_diff.rs) returned early
for the no-change case with the marker and ✓ No card-parse changes detected. and nothing else — no
base, no head — while the changed-cards branch printed only the baseline. Comment 5153742352 did
re-run for bee984f80 (updated_at 2026-08-02T06:57:35Z); its body is byte-identical to that early
return. So the sticky is now head-bound on both branches. This is a class-wide provenance fix, not
a #6851 one.

That generator is ordinary crate source, so no workflow file is touched. The head is sourced from the
HEAD_SHA the CI step already exports, with a --head-sha flag mirroring the existing --base-sha.
A git rev-parse HEAD fallback would have been wrong rather than merely inelegant — that job checks
out the synthetic PR merge commit, so git reports the merge SHA, never pull_request.head.sha. Two
constraints shaped the wording, both pinned by assertions with revert-probes: scripts/pr_review.py
requires the marker to stay the first line, and it classifies sticky state by the substrings
"Baseline pending" and "signature(s)", so the head line must contain neither or a no-change sticky
misclassifies as real parse changes.

CodeRabbit then raised two findings on that generator, both taken. A present-but-valueless
--base-sha/--head-sha silently fell back, so a report could name a commit it does not describe;
both now error. The pre-existing --base-sha line was fixed alongside the one this branch added —
leaving it lenient would put two flags in the same provenance category on different rules. The
output flags stay lenient and a test pins that asymmetry as deliberate. parse-diff.json carried no
provenance at all while both the report and the sticky send a truncated reader to open it, so it now
carries both SHAs; nothing in the repo deserializes that artifact (its four references are two
workflow write/upload paths and two prose mentions), so added keys break no consumer. Neither defect
is reachable through CI, which never passes --head-sha and always pairs its flags with values —
this hardens the local and manual surface, and is not a CI bug fix.

Two pre-existing defects found while measuring are filed separately rather than folded in: (1) a stale
CopyTargetChoice after a successful copy entry routes the entry event to
collect_triggers_into_deferred, swallowing ETB observers on copy-token entries (reproduces
identically with and without this diff); (2) conceding at the as-enters pause panics on unmodified
main at elimination.rs:1069.

Validation Failures

The round-2 disclosure that the Tribute two-pause class realizes after its trigger scan is retired
— that defect is fixed, not disclosed.
Remaining disclosures, none blocking:

  1. The Err-restore arm of the new boundary block is unmeasured. It mirrors the existing
    restore-on-Err arm a few lines above it, but no test drives a failing post-action pipeline there.
  2. scan_from vs 0 has a measured mechanism but no discriminating test. Widening to 0 flips
    nothing today. It is still wrong: collect_and_drain_observer_triggers_if_settled never writes
    consumed_before_priority_trigger_events, and the exclusion filter covers ZoneChanged only, so
    every non-ZoneChanged observer event it already collected would be collected twice.
  3. The convergence is the entry pair only. With event_start = scan_from, the direct-return
    handler's own events — from effects::tribute::apply_paid, and from
    resume_pending_continuation_if_priority, which can resolve a whole ability chain — still get no
    CR 603.2 scan. That was true before this change too; it is not a regression, but it means "converges
    through the normal pipeline" is exact about the realized entry and not about everything the handler
    emitted.
  4. Conceding with a parked entry is reachable on the TributeChoice shape.
    abandon_source_bound_resolution_prompt clears the park, but its gate matches only
    WaitingFor::NamedChoice and WaitingFor::OpponentGuess, so the Painter sibling is safe by
    construction and Tribute is not. Unmeasured; adjacent to the separately-filed concede panic above.
  5. The life-safety preview's contract widens. apply_interaction_pre_reconciliation_for_life_safety
    documents itself as "exactly the reducer portion" and returns without finish_action_boundary; on a
    parked-entry route it would now also run the pipeline. Not reachable today — that root cannot
    produce a parked copy-token entry, and it operates on a clone.
  6. Two residuals in change-set B are left unfixed by design, because they live in
    .github/workflows/**, which this PR treats as a hard stop.
    (a) ci.yml's baseline-not-published
    fallback body names the base only; adding the head there needs one printf argument. (b) When the
    engine-source hash is unchanged the step sets produced=false, so no artifact is uploaded and the
    sticky silently retains its previous head's body — no generator change can bind a comment that was
    never regenerated. Offered as text in the review thread for a maintainer to apply.

A note on the required-evidence item. The sticky names the head it was generated from, so landing
these commits necessarily moves it: the regenerated comment will identify the new head, not
bee984f80. A literal identity check against bee984f80… is unsatisfiable by construction; the
substantive requirement — that the artifact be bound to a head — is met.

CI Failures

None.

Pre-existing and untouched: CR733_CENSUS_STRICT=1 fails on census.site_count (pinned 3597; a
census run on the pristine pre-rework tree 057d80a65 gives 3669), so that pin was already stale
before this rework. The always-on cr733_authority_matrix_covers_the_fresh_write_census gate is
green. No GameState field is added in this round, so the matrix is unchanged.

Summary by CodeRabbit

  • Bug Fixes

    • Improved copy-token battlefield entry handling when resolutions are paused or require choices.
    • Ensured token entries and related triggers occur in the correct order and exactly once.
    • Prevented phantom events for tokens that no longer exist.
    • Improved cleanup when deferred token resolutions are abandoned or turns change.
  • Tools

    • Coverage reports now identify the specific head and base commits used to generate them.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Suppressed copy-token entries now park battlefield-entry state until copy and as-enters processing settles. Shared helpers record and emit each entry once. Coverage reports now include the head commit SHA.

Changes

Deferred Token Battlefield Entry

Layer / File(s) Summary
Define and record deferred entries
crates/engine/src/types/game_state.rs, crates/engine/src/game/effects/token.rs
GameState stores pending token metadata. Token helpers share snapshots and recorder-assigned indexes.
Finalize and flush parked entries
crates/engine/src/game/engine_replacement.rs, crates/engine/src/game/effects/counters.rs, crates/engine/src/game/engine.rs
Suppressed routes park entries. Settled paths flush them before deferred event replay and trigger scanning.
Clear abandoned deferred state
crates/engine/src/game/elimination.rs, crates/engine/src/game/scenario_db.rs, crates/engine/src/game/turns.rs
Cleanup paths clear pending token battlefield-entry state.
Validate deferred lifecycle behavior
crates/engine/src/game/effects/token.rs, crates/engine/tests/integration/token_zone_change_index.rs
Tests cover prompts, settlement, serialization, identity, trigger timing, unique indexes, and exactly-once realization.

Coverage Report Provenance

Layer / File(s) Summary
Add head SHA report provenance
crates/engine/src/bin/coverage_parse_diff.rs
The CLI accepts --head-sha or HEAD_SHA. Generated Markdown and JSON reports include head and base SHA provenance.

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

Possibly related PRs

Suggested reviewers: matthewevans

Sequence Diagram(s)

sequenceDiagram
  participant CopyTokenResolution
  participant GameState
  participant TokenEntryHelpers
  participant PriorityPipeline
  CopyTokenResolution->>GameState: Park suppressed token entry
  CopyTokenResolution->>TokenEntryHelpers: Schedule object-only emission
  TokenEntryHelpers->>GameState: Flush settled battlefield entry
  TokenEntryHelpers->>PriorityPipeline: Emit entry events for scanning
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR does not implement issue #39, which requires automated README coverage badge updates from latest card data. Add the automated README coverage badge update required by issue #39, or link the PR to issues that match the implemented engine and parse-diff changes.
Out of Scope Changes check ⚠️ Warning The token lifecycle redesign and parse-diff provenance work are unrelated to issue #39's README coverage badge objective. Remove unrelated engine and parse-diff changes, or update the linked issues to document those objectives and link issue #39 to the appropriate PR.
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main engine change: recording token battlefield entries when their events are suppressed.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@matthewevans matthewevans self-assigned this Aug 1, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: record_committed_token_entry now writes the Suppress-route rows before BecomeCopy, but their content is refreshed only by emit_recorded_token_entry_events in the fully unpaused tail. That misses the very mid-entry pauses this change says it supports.

For example, an Embalm Vizier copying Painter's Servant reaches finish_copy_target_choice_entry after BecomeCopy; current_self_enter_replacement_choice then raises Painter's mandatory color NamedChoice and this function returns before the emit/refresh at the caller's tail. The NamedChoice resume path replays deferred entry events, but it does not invoke emit_recorded_token_entry_events. The two ledger rows therefore remain the pre-copy 0/0 Vizier snapshot instead of the realized Painter's Servant entry.

That is materially incorrect for the CR 400.7/403.3 lookup consumers this PR is updating, and it is the opposite of the unpaused Bear assertion added here. Please carry the record refresh through each post-copy pause/resume (or postpone record construction until the copied characteristics and mandatory as-enters choices are complete), then add an integration regression that drives this copied mandatory-choice pause and asserts both ledgers describe the realized copy exactly once.

@matthewevans matthewevans added the bug Bug fix label Aug 1, 2026
@matthewevans matthewevans removed their assignment Aug 1, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/game/effects/token.rs`:
- Around line 1835-1891: Centralize battlefield-entry recording through
record_zone_change for the production paths in counters.rs, token_copy.rs, and
gift_delivery.rs, ensuring each entry creates both synchronized ledger rows
before emitting ZoneChanged events. Update emit_recorded_token_entry_events to
detect and repair or reject mismatched ledgers, specifically preventing the
(None, Some(_)) case from appending a duplicate battlefield-entry record.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1d51e64b-761f-4273-9f97-c6bed7a3d8b8

📥 Commits

Reviewing files that changed from the base of the PR and between 9169d8f and 1bd2705.

📒 Files selected for processing (4)
  • crates/engine/src/game/effects/counters.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/engine_replacement.rs
  • crates/engine/tests/integration/token_zone_change_index.rs

Comment thread crates/engine/src/game/effects/token.rs Outdated
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Generated for head f8e7a25a136dbaa1eca8ee2a31cf57d1e7578cbb.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@lgray

lgray commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Confirmed, and it is worse than you stated. I reproduced your exact path as an integration
probe — Embalm Vizier token → CopyTargetChoice → Painter's Servant → the copy's mandatory
as-enters NamedChoice:

prompts      = ["ReplacementChoice(2)", "CopyTargetChoice", "NamedChoice(5)"]
live name    = Some("Painter's Servant")          <- the object IS the realized copy
zone_changes = [("Vizier of Many Faces", 0/0)]    <- pre-copy row, as you predicted
bf_entries   = [("Vizier of Many Faces", ["Zombie"])]
zc_events    = []                                 <- no entry event emitted AT ALL

Two things I did not know before measuring:

1. The emit is lost on this route too, not just the refresh. zc_events is empty — the token
enters with no observable arrival. The paused_entry_emit hand-down I added covers only the
ETB-counter pause return, which is the one of the five that stashes counter_pause_post_actions.
The other four return Ok(Some(waiting_for)) without stashing, so neither the emit nor the refresh
survives. The NamedChoice resume (engine_resolution_choices.rs:5756) calls
replay_deferred_entry_events and returns — it never reaches
emit_recorded_token_entry_events, exactly as you said.

2. Baseline separation. Same probe grafted onto merged main (9169d8f44):

zone_changes bf_entries emit
main 9169d8f44 [] [] none
this PR 1bd27055e [("Vizier…", 0/0)] [("Vizier…", ["Zombie"])] none

So the missing emit is pre-existing and unchanged by this PR; what this PR changes on this route is
no rowstale row. I am not offering that as mitigation — I agree a wrong-identity row is
materially incorrect for the CR 400.7 / 403.3 consumers, and for a name- or type-scoped lookup it
is worse than an absent one. Recording it there was my error: I placed the write at the only point
all five routes reach, without checking that the content at that point is not yet the entering
object.

Also confirmed: CodeRabbit's related ledger-mismatch Minor. Seven production callers write
battlefield_entries_this_turn without a zone-change row — gift_delivery.rs:157,
conjure.rs:191, counters.rs:518/558/637, token_copy.rs:851/969 — so the (None, Some(_))
state is structurally reachable and my (None, _) arm would append a duplicate entry row. Same
root cause as yours: two ledgers with more than one writer and no single authority.

Taking your second option (postpone construction until the copied characteristics and the
mandatory as-enters choices are complete) as the design direction, since threading a refresh
through four unrelated resume mechanisms re-creates the split-writer problem rather than removing
it. Going through plan → independent plan review → implement → independent implementation review
before I push, and the regression will be the probe above, driving the copied mandatory-choice
pause and asserting both ledgers describe the realized Painter's Servant exactly once.

Marking this PR draft while that is in flight so it is not sitting on your queue.

@lgray
lgray marked this pull request as draft August 1, 2026 22:28
@matthewevans matthewevans self-assigned this Aug 1, 2026
@matthewevans

Copy link
Copy Markdown
Member

Thanks for reproducing this and for separating the baseline behavior from the regression introduced here. I confirmed the follow-up against the unchanged head: it does not resolve the requested change, and the current CHANGES_REQUESTED review remains in effect.

The Paint-er mandatory-choice path establishes both failures on this head: the newly added rows retain the pre-copy identity, and the deferred entry emit is dropped. Your confirmation of CodeRabbit's (None, Some(_)) duplicate-row path also supports moving the work to one authoritative, resume-safe record/emit lifecycle rather than extending the current split route.

Keeping this draft while you rework it is appropriate. Please push the redesigned implementation and the production integration regression before requesting another review.

@matthewevans matthewevans removed their assignment Aug 1, 2026
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…eld entries

CR 400.7 + CR 403.3. A `TokenEntryEventEmission::Suppress` token's battlefield entry was
recorded by the finalize tail BEFORE `BecomeCopy` resolved and its content refreshed only
in `handle_copy_target_choice`'s fully-unpaused tail, so every mid-entry pause kept a
pre-copy row — and four of the five pause returns in `finish_copy_target_choice_entry`
never reached the deferred emit at all. Measured on the maintainer's named path (Embalm
Vizier of Many Faces copying Painter's Servant, whose mandatory as-enters `NamedChoice`
returns from `finish_copy_target_choice_entry`): both ledgers held a pre-copy
`Vizier of Many Faces` 0/0 row and no entry event was emitted.

Replace the split record-here / refresh-there route with one postponed lifecycle:

- The `Suppress` finalize tail RECORDS NOTHING. It parks the entry on
  `GameState::pending_token_battlefield_entry` (serde-persisted, so it survives arbitrary
  client round trips — CR 614.12a puts the as-enters choice before the permanent enters).
- `token::flush_pending_token_battlefield_entry` is the sole consumer. It takes the parked
  value with `Option::take_if` and, over that one owned value, calls
  `restrictions::record_zone_change` — the single writer of BOTH CR 400.7 ledgers — and
  pushes the CR 603.6a entry pair. "Recorded but never emitted", "emitted but never
  recorded", and the duplicate battlefield-entry row are unrepresentable rather than
  guarded.
- `emit_recorded_token_entry_events` is deleted. Its `(None, Some(_))` arm — which appended
  a duplicate `battlefield_entries_this_turn` row, reported by CodeRabbit — no longer has a
  representation: nothing reads a previously-written row back.

Three convergence points feed that one authority, and none subsumes another (each is
revert-probed):

- `finish_copy_target_choice_entry`, for the unpaused route, whose action ends on a stale
  `CopyTargetChoice` and so would otherwise realize one client round trip late;
- the `EmitCommittedCopyTokenEntry` post-action, inside the CR 616.1 counter drain, ahead
  of that action's trigger scan;
- `token::realize_settled_token_battlefield_entry`, one gate (settled `WaitingFor::Priority`
  + the token still on the battlefield) called from two sites in `engine.rs`: inside
  `apply_action` immediately before `run_post_action_pipeline`, so the realized pair is
  trigger-scanned in its own action (CR 603.6a — Soul Warden observes the copy token), and
  again at the action boundary as the backstop for handlers that return an `ActionResult`
  straight out of the reducer match.

The settled gate is pause-shape-agnostic by construction: it names the settled state, the
complement of "any pause", so no `WaitingFor` variant appears in it. That is what covers the
as-enters class whose continuation raises a SECOND pause (11 Tribute creatures and the
`RevealHand` chains), which a resume-arm hook would have missed.

Known partial, measured and pinned: `handle_tribute_choice` builds its `ActionResult`
directly out of the reducer match, so the two-pause class realizes at the boundary — after
the trigger scan — and its ETB observers do not fire (Soul Warden delta 0). Both ledgers and
the emit are correct there; this is strictly better than the previous behaviour, which
emitted nothing at all for that class, and it is a property of the reducer's direct-return
handlers rather than of this lifecycle.

Regressions drive the real cast pipeline and pin all four routes, each with a reach-guard on
the exact prompt sequence: the Painter's Servant mandatory-choice pause (post-copy identity
on both ledgers exactly once, a non-empty emitted pair carrying the recorder-assigned index,
and the ETB observer at +1), the Fanatic of Xenagos two-pause class, the CR 616.1
counter-ordering pause, and the unpaused route; plus a positive control on the untouched
`Emit` path.

Reported by matthewevans (phase-rs#6851 review) and CodeRabbit; both verified before fixing.

Assisted-by: ClaudeCode:claude-opus-5
@lgray
lgray force-pushed the fix/cr403-suppressed-token-entry-record branch from 1bd2705 to a53bde0 Compare August 2, 2026 06:21
@lgray

lgray commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Redesigned and pushed. I took your second option — postpone construction until the copied
characteristics and the mandatory as-enters choices are complete — and built it as the single
resume-safe lifecycle you asked for, rather than threading a refresh through the resume mechanisms.

The shape

The Suppress finalize tail now records nothing. It parks the entry on
GameState::pending_token_battlefield_entry (serde-persisted, so it survives arbitrary client round
trips). token::flush_pending_token_battlefield_entry is the sole consumer: it takes the parked value
with Option::take_if and, over that one owned value, calls restrictions::record_zone_change — the
single writer of both CR 400.7 ledgers — and pushes the CR 603.6a pair. "Recorded but never emitted",
"emitted but never recorded", and the duplicate battlefield-entry row are now unrepresentable rather
than guarded.

emit_recorded_token_entry_events is deleted outright, which is what closes CodeRabbit's
(None, Some(_)) find
— good catch, it was the read-back of a previously written row that made the
mismatch expressible at all. Nothing reads a row back now.

Three convergence points feed that one authority; each is independently revert-probed, and none
subsumes another:

point where why it can't be dropped
(a) finish_copy_target_choice_entry the unpaused route's action ends on a stale CopyTargetChoice, so a settled-Priority seam alone would realize it one client round trip late
(b) the EmitCommittedCopyTokenEntry post-action realizes inside the CR 616.1 counter drain, ahead of that action's trigger scan
(c) token::realize_settled_token_battlefield_entry — one gate, two engine.rs call sites in apply_action before run_post_action_pipeline (so the pair is trigger-scanned in its own action), plus the action-boundary backstop for handlers that return an ActionResult straight out of the reducer match

(c)'s gate is pause-shape-agnostic by construction: it names the settled state, the complement of
"any pause", so no WaitingFor variant appears in it. That is what covers the as-enters class whose
continuation raises a second pause — 11 Tribute creatures plus the RevealHand chains, all legal
Vizier copy targets. A hook on the NamedChoice resume arm would have missed every one of them, and
would have turned your "rows are stale" into "rows do not exist" for that class.

Measured, on your named path

Embalm Vizier of Many Faces → copy Painter's Servant → mandatory colour NamedChoice, with a Soul
Warden on the battlefield. Reach-guard pins prompts == ["ReplacementChoice(2)", "CopyTargetChoice", "NamedChoice(5)"].

zone_changes_this_turn battlefield_entries_this_turn emitted pair Soul Warden
merged main [] [] none +0
previous head 1bd27055e Vizier of Many Faces 0/0 Vizier of Many Faces none +0
this head Painter's Servant 1/3 ×1 Painter's Servant ×1 ZoneChanged + TokenCreated, carrying the recorder-assigned index +1

One correction to the review, and one accepted partial

Correction. Your review says the NamedChoice resume path "replays deferred entry events".
Measured: on this route state.deferred_entry_events is empty, so the arm's own gate is false and the
arm is skipped entirely — it does not replay either. Adding an emit inside it would have been dead
code. It does not change the shape you asked for; it is why the fix does not live there.

Accepted partial, pinned rather than left silent. handle_tribute_choice builds its
ActionResult directly out of the reducer match, so the two-pause class realizes at the boundary —
after the trigger scan — and its ETB observers do not fire (Soul Warden +0). Both ledgers and the
emit are correct there. That is strictly better than the previous behaviour (which emitted nothing at
all for that class) and it is a property of the reducer's direct-return handlers rather than of this
lifecycle, so I did not widen scope to chase it. There is a test pinning it at 0 whose doc comment
says a failure there is the fix landing, not a regression. Say the word if you want it folded in.

Two pre-existing defects found while measuring — filed, not folded in

  1. After a successful copy-token entry a stale CopyTargetChoice remains, so the entry event is
    routed to collect_triggers_into_deferred and never drained: ETB observers are swallowed on the
    unpaused copy-token route too (Soul Warden +0 with the copy, +1 on a decline control). Reproduces
    identically with and without this PR's diff.
  2. Conceding at the as-enters pause panics on unmodified main
    elimination.rs:1069 UnexpectedTop { AbilityContinuation, PostReplacement }. This is why the
    backstop's departed-token branch is argued rather than fixture-covered.

Also noted, not acted on: CR733_CENSUS_STRICT=1 fails on census.site_count (pinned 3597; a census
on the pristine base tree gives 3669), so that pin was already stale before this branch. The always-on
gate is green and this PR's new field entry is freshly regenerated and agrees with a live census.

Process

Full /engine-implementer pipeline this time, all steps in fresh contexts: plan → 2 independent
plan-review rounds (round 1 returned 1 BLOCKING + 3 MED + 3 LOW; round 2 returned 1 LOW, prose-only)
→ implement → 2 independent implementation-review rounds (round 1 returned 2 MED + 3 LOW —
including the observation that my first backstop placement left your named path's ETB observer at 0 —
round 2 returned 1 LOW on a stale fixture entry) → delta verification. Every finding fixed with code
and re-measured; every revert probe in the report was actually run and restored byte-identically.

Disclosure: round 2's plan-review finding was a prose-only correction to the plan document, applied
verbatim from the reviewer's own text in the orchestrating context rather than by a fresh planner; no
third plan-review round was run on the plan.

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Aug 2, 2026
@lgray
lgray marked this pull request as ready for review August 2, 2026 06:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

808-848: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the token == None case explicit so per-step counters cannot read as vacuous zeros.

matches_token returns false for every event and every ledger row when token is None. On the declined route drive.token is never set, so all four counters in CopyEntryStep are zero by construction rather than by measurement. declined_copy_replacement_records_the_token_entry_without_parking_it reads the ledgers from runner.state() directly and only uses steps for parked, so it is not blind today. A later per-step assertion on that route would pass for the wrong reason.

Consider making the unmeasurable case unrepresentable, for example by storing Option<TokenCounts> in CopyEntryStep when the token id is unknown, or by documenting on the struct that counters are meaningless while token is None.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/token_zone_change_index.rs` around lines 808
- 848, Make the unknown-token case explicit in token_entry_step and
CopyEntryStep so per-step counters cannot appear as measured zeroes when token
is None. Prefer representing the counter group as Option<TokenCounts> (or an
equivalent explicit unmeasurable state), returning None when no token id is
available and preserving normal counts for Some(token); update consumers and
assertions to unwrap or handle that state while keeping parked independently
measurable.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/game/turns.rs`:
- Around line 1161-1166: Update the comment immediately above
pending_token_battlefield_entry’s turn-boundary reset to cite CR 514.2 as the
authority for ending “this turn” state, while retaining CR 400.7 and CR 603.6a
for entry realization and ETB-trigger behavior.

In `@crates/engine/tests/integration/token_zone_change_index.rs`:
- Around line 1094-1095: Update the dedup-key descriptions near the referenced
comments to remove the “CR 603.2c” citation and describe the turn-zone-change
index and batched-replay deduplication as engine implementation details. Apply
the same wording correction to the corresponding description near the later
occurrence, without changing the behavior or surrounding test logic.

---

Nitpick comments:
In `@crates/engine/tests/integration/token_zone_change_index.rs`:
- Around line 808-848: Make the unknown-token case explicit in token_entry_step
and CopyEntryStep so per-step counters cannot appear as measured zeroes when
token is None. Prefer representing the counter group as Option<TokenCounts> (or
an equivalent explicit unmeasurable state), returning None when no token id is
available and preserving normal counts for Some(token); update consumers and
assertions to unwrap or handle that state while keeping parked independently
measurable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cbd662eb-8fa2-4ac5-b1dd-476d8aa3db9a

📥 Commits

Reviewing files that changed from the base of the PR and between 1bd2705 and a53bde0.

⛔ Files ignored due to path filters (1)
  • crates/engine/tests/fixtures/cr733/authority_matrix.json.gz is excluded by !**/*.gz
📒 Files selected for processing (9)
  • crates/engine/src/game/effects/counters.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/elimination.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_replacement.rs
  • crates/engine/src/game/scenario_db.rs
  • crates/engine/src/game/turns.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/tests/integration/token_zone_change_index.rs

Comment thread crates/engine/src/game/turns.rs Outdated
Comment thread crates/engine/tests/integration/token_zone_change_index.rs Outdated
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
CodeRabbit on phase-rs#6851. The per-turn clear of `pending_token_battlefield_entry` cited
only CR 400.7, which names the ledgers it defends but is not the authority for the
reset itself. CR 514.2 is: the cleanup step is where all "until end of turn" and
"this turn" effects end. Cite both.

Also tightens the one place that read as though CR 603.2c names
`turn_zone_change_index`. The index is the engine's own key; the batched
zone-change replay guard dedups on it to hold CR 603.2c's once-per-occurrence
bound. The citation stays — it is the rule the guard implements, and it matches
this file's existing module header — but the sentence now separates rule from
mechanism.

Comment-only; no behaviour change.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

@coderabbitai — both addressed in bee984f80, one accepted, one partially. Comment-only; git diff -U0
shows no added line outside // / ///.

1. turns.rs:1166 (Major) — accepted, you are right. The reset cited only CR 400.7, which names
the ledgers it defends but is not the authority for the reset itself. Verified
docs/MagicCompRules.txt:2438"514.2. Second, the following actions happen simultaneously: all
damage marked on permanents … is removed and all 'until end of turn' and 'this turn' effects end."

Now cites CR 514.2 + CR 400.7, with CR 514.2 carrying the turn-boundary authority and CR 400.7
naming the two ledgers.

2. token_zone_change_index.rs:1095 (Minor) — the imprecision is real, the removal is not the right
fix.
You are correct that CR 603.2c does not define turn_zone_change_index, and
"recorder-assigned CR 603.2c dedup key" did read as though it does. Fixed by separating rule from
mechanism: the index is now described as the engine's own key that the batched zone-change replay
guard dedups on in order to hold CR 603.2c's once-per-occurrence bound.

The citation itself stays, for two measured reasons. First, this file already establishes that
convention in nine pre-existing places — its module header at line 10 names
(definition_ref, turn_zone_change_index) and CR 603.2c in the same sentence, and lines 1, 30, 137,
171, 214, 220, 296 and 339 do the same. Dropping it from the two lines this PR adds would leave the
new code inconsistent with the file it lives in. Second, CLAUDE.md makes annotation mandatory for
code that implements a rule, which is exactly what the dedup does. Removing a correct rule citation
to avoid an ambiguous phrasing trades a real requirement for a wording problem that the rewrite
already solves.

No change at line 1382, and here I think the finding does not apply. That doc comment opens
CR 603.2c — a postponed entry must not collide with a normally-recorded one and its body already
says the two entries "must occupy DISTINCT turn_zone_change_index values, because the batched
zone-change replay guard dedups on that index
". Rule attached to the requirement, index described
as the guard's key — the separation you asked for is already there.

Thanks also for the (None, Some(_)) find on the previous revision; that one is what motivated
deleting emit_recorded_token_entry_events outright rather than guarding it, so the mismatched-ledger
state no longer has a representation.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline bee984f80
git show --format=fuller --unified=12 bee984f80 -- \
  crates/engine/src/game/turns.rs \
  crates/engine/tests/integration/token_zone_change_index.rs

printf '\n--- turns.rs context ---\n'
nl -ba crates/engine/src/game/turns.rs | sed -n '1150,1185p'

printf '\n--- token_zone_change_index.rs contexts ---\n'
nl -ba crates/engine/tests/integration/token_zone_change_index.rs | sed -n '1070,1120p'
nl -ba crates/engine/tests/integration/token_zone_change_index.rs | sed -n '1360,1400p'

printf '\n--- annotation guidance ---\n'
fd -a -t f '^CLAUDE\.md$' . -x sh -c 'echo "--- $1 ---"; rg -n -C3 "annotation|rule|citation|CR 603\\.2c|turn_zone_change_index" "$1" || true' sh {}

Length of output: 6987


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- current Line 1382 context ---'
sed -n '1360,1405p' crates/engine/tests/integration/token_zone_change_index.rs

printf '%s\n' '--- module header and prior CR 603.2c annotations ---'
sed -n '1,55p' crates/engine/tests/integration/token_zone_change_index.rs
grep -n -C 2 -E 'CR 603\.2c|turn_zone_change_index' \
  crates/engine/tests/integration/token_zone_change_index.rs || true

printf '%s\n' '--- CLAUDE.md guidance ---'
find . -name CLAUDE.md -type f -print -exec sh -c '
  echo "--- $1 ---"
  grep -n -i -C 3 -E "annotation|rule|citation|MagicCompRules|definition_ref" "$1" || true
' sh {} \;

Length of output: 46071


@lgray Verified bee984f80.

The turns.rs comment now correctly identifies CR 514.2 as the authority for the turn-boundary reset. CR 400.7 now has the limited and correct ledger context. The Major finding is addressed.

The comment at Line 1095 now separates turn_zone_change_index from CR 603.2c. It describes the index as an engine key and CR 603.2c as the once-per-occurrence requirement that the replay guard enforces. The Minor finding is addressed.

I withdraw the Line 1382 finding. The existing comment already makes the same distinction. It states the CR 603.2c requirement first, then identifies turn_zone_change_index as the replay guard's deduplication key. The retained CR annotation also follows the repository requirement for rule-related code.

🐇

You are interacting with an AI system.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request changes — the new parked-entry lifecycle records the row, but still drops ETB trigger emission on a reachable two-pause continuation.

🔴 Blocker

crates/engine/src/game/effects/token.rs:1904-1908 explicitly documents that the handle_tribute_choice direct-return path realizes the parked entry only at the action boundary, after its trigger scan. crates/engine/src/game/engine.rs:376-385 confirms that placement. The production-path test in crates/engine/tests/integration/token_zone_change_index.rs:1150-1161 reaches CopyTargetChoice → NamedChoice → TributeChoice, then deliberately asserts Soul Warden’s life delta is 0 at :1213-1225. That result is rules-incorrect: CR 603.6a says ETB abilities are checked whenever an event puts a permanent onto the battlefield (verified local corpus docs/MagicCompRules.txt:2599; CR 603.2c at :2567). Make the direct-return continuation converge through the normal post-action trigger pipeline, or flush before that handler’s trigger scan; the regression must assert Soul Warden gains 1.

🔴 Required evidence

The required <!-- coverage-parse-diff --> comment for current engine source is not bound to the head: issue comment 5153742352, updated for this run, says only “No card-parse changes detected” with no SHA. Regenerate/update the artifact so it identifies bee984f809e084d2bd0c71c4bbbb3d67ac8d13b4.

✅ Clean

The prior stale/lost Suppress-route ledger finding is addressed by the park/flush design; the old CodeRabbit lifecycle finding is stale. Current CI is green, but it does not remedy the reachable trigger defect.

Recommendation: request changes on this head, then resubmit with the trigger-pipeline fix, a discriminating +1 ETB assertion, and a head-bound parse-diff artifact.

lgray added 4 commits August 2, 2026 04:58
…eld entries

CR 400.7 + CR 403.3. A `TokenEntryEventEmission::Suppress` token's battlefield entry was
recorded by the finalize tail BEFORE `BecomeCopy` resolved and its content refreshed only
in `handle_copy_target_choice`'s fully-unpaused tail, so every mid-entry pause kept a
pre-copy row — and four of the five pause returns in `finish_copy_target_choice_entry`
never reached the deferred emit at all. Measured on the maintainer's named path (Embalm
Vizier of Many Faces copying Painter's Servant, whose mandatory as-enters `NamedChoice`
returns from `finish_copy_target_choice_entry`): both ledgers held a pre-copy
`Vizier of Many Faces` 0/0 row and no entry event was emitted.

Replace the split record-here / refresh-there route with one postponed lifecycle:

- The `Suppress` finalize tail RECORDS NOTHING. It parks the entry on
  `GameState::pending_token_battlefield_entry` (serde-persisted, so it survives arbitrary
  client round trips — CR 614.12a puts the as-enters choice before the permanent enters).
- `token::flush_pending_token_battlefield_entry` is the sole consumer. It takes the parked
  value with `Option::take_if` and, over that one owned value, calls
  `restrictions::record_zone_change` — the single writer of BOTH CR 400.7 ledgers — and
  pushes the CR 603.6a entry pair. "Recorded but never emitted", "emitted but never
  recorded", and the duplicate battlefield-entry row are unrepresentable rather than
  guarded.
- `emit_recorded_token_entry_events` is deleted. Its `(None, Some(_))` arm — which appended
  a duplicate `battlefield_entries_this_turn` row, reported by CodeRabbit — no longer has a
  representation: nothing reads a previously-written row back.

Three convergence points feed that one authority, and none subsumes another (each is
revert-probed):

- `finish_copy_target_choice_entry`, for the unpaused route, whose action ends on a stale
  `CopyTargetChoice` and so would otherwise realize one client round trip late;
- the `EmitCommittedCopyTokenEntry` post-action, inside the CR 616.1 counter drain, ahead
  of that action's trigger scan;
- `token::realize_settled_token_battlefield_entry`, one gate (settled `WaitingFor::Priority`
  + the token still on the battlefield) called from two sites in `engine.rs`: inside
  `apply_action` immediately before `run_post_action_pipeline`, so the realized pair is
  trigger-scanned in its own action (CR 603.6a — Soul Warden observes the copy token), and
  again at the action boundary as the backstop for handlers that return an `ActionResult`
  straight out of the reducer match.

The settled gate is pause-shape-agnostic by construction: it names the settled state, the
complement of "any pause", so no `WaitingFor` variant appears in it. That is what covers the
as-enters class whose continuation raises a SECOND pause (11 Tribute creatures and the
`RevealHand` chains), which a resume-arm hook would have missed.

Known partial, measured and pinned: `handle_tribute_choice` builds its `ActionResult`
directly out of the reducer match, so the two-pause class realizes at the boundary — after
the trigger scan — and its ETB observers do not fire (Soul Warden delta 0). Both ledgers and
the emit are correct there; this is strictly better than the previous behaviour, which
emitted nothing at all for that class, and it is a property of the reducer's direct-return
handlers rather than of this lifecycle.

Regressions drive the real cast pipeline and pin all four routes, each with a reach-guard on
the exact prompt sequence: the Painter's Servant mandatory-choice pause (post-copy identity
on both ledgers exactly once, a non-empty emitted pair carrying the recorder-assigned index,
and the ETB observer at +1), the Fanatic of Xenagos two-pause class, the CR 616.1
counter-ordering pause, and the unpaused route; plus a positive control on the untouched
`Emit` path.

Reported by matthewevans (phase-rs#6851 review) and CodeRabbit; both verified before fixing.

Assisted-by: ClaudeCode:claude-opus-5
CodeRabbit on phase-rs#6851. The per-turn clear of `pending_token_battlefield_entry` cited
only CR 400.7, which names the ledgers it defends but is not the authority for the
reset itself. CR 514.2 is: the cleanup step is where all "until end of turn" and
"this turn" effects end. Cite both.

Also tightens the one place that read as though CR 603.2c names
`turn_zone_change_index`. The index is the engine's own key; the batched
zone-change replay guard dedups on it to hold CR 603.2c's once-per-occurrence
bound. The citation stays — it is the rule the guard implements, and it matches
this file's existing module header — but the sentence now separates rule from
mechanism.

Comment-only; no behaviour change.

Assisted-by: ClaudeCode:claude-opus-5
The reducer's direct-return arms build their `ActionResult` inside the
match and never reach `apply_action`'s tail pipeline, so a CR 403.3 token
battlefield entry realized on one of those routes entered with no ETB
observer ever seeing it — CR 603.6a requires the check to run whenever an
event puts a permanent onto the battlefield.

`realize_settled_token_battlefield_entry` now reports whether it realized,
and `apply_action_boundary_core` runs the same
`run_post_action_pipeline_from` the tail path delegates to, over the slice
the realization appended. The convergence is arm-agnostic: the match has 16
non-error direct returns and this is inert on the 15 that never park.

Measured on the reviewer's named path (Embalm Vizier of Many Faces -> copy
Painter's Servant -> mandatory colour NamedChoice -> Tribute, Soul Warden on
the battlefield): Soul Warden's life delta goes 0 -> 1. Reverting the block
reproduces the 0, failing at the reach-guard first; with the guard relaxed
the life assertion alone flips. The regression pins "OrderTriggers(2)" —
Tribute is declined, so Fanatic's CR 702.104b intervening-if is true and its
ETB fires alongside Soul Warden's, both P0's, so CR 603.3b requires an
ordering prompt.

Both `waiting_for` writes are required: `finish_action_boundary` ->
`sync_waiting_for` copies the result into the state, so a state-only write
is undone, and the life-safety preview never calls `finish_action_boundary`,
so a result-only write is insufficient.

Assisted-by: ClaudeCode:claude-opus-5
`render_markdown` returned early for the no-change case with the marker and
"No card-parse changes detected" and nothing else — no base, no head — while
the changed-cards branch printed only the baseline. No branch had ever
emitted a head SHA, so the sticky comment could not be tied to the commit it
described and regenerating it could not help.

Both branches now carry the head, sourced from the `HEAD_SHA` the CI step
already exports, with a `--head-sha` flag mirroring the existing
`--base-sha`. Deriving it from git would be wrong rather than merely
inelegant: that job checks out the synthetic PR merge commit, so git reports
the merge SHA, never the PR head.

Two consumer constraints shape the wording, each pinned by an assertion with
a revert-probe: `scripts/pr_review.py` requires the marker to remain the
first line, and it classifies sticky state by the substrings "Baseline
pending" and "signature(s)", so the head line must contain neither or a
no-change sticky misclassifies as real parse changes.

Assisted-by: ClaudeCode:claude-opus-5
@lgray
lgray force-pushed the fix/cr403-suppressed-token-entry-record branch from bee984f to 35f71f0 Compare August 2, 2026 10:23
@lgray

lgray commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks — both items are addressed at 35f71f0d8. Measurements below, including one where your instruction turned out not to be satisfiable as written and I want to show you why rather than quietly do something else.

🔴 Blocker — direct-return continuation now converges through the post-action pipeline

You offered two remedies. I measured the second one first, and it has nothing to attach to: handle_tribute_choice (crates/engine/src/game/engine_payment_choices.rs:374-403) has no trigger scan. Its tail is set_active_priorityresume_pending_continuation_if_priority, and neither scans. Grafting the sibling handler's drain helper also fails — can_drain_deferred_triggers refuses while resolution_stack.len() == 1, which is exactly the state that handler returns in. So "flush before that handler's trigger scan" has no seam; I took your first remedy instead.

realize_settled_token_battlefield_entry now returns whether it realized, and apply_action_boundary_core runs the normal run_post_action_pipeline_from when it did. That is one convergence for every direct-return arm, not a Tribute special case — I censused the reducer match and there are 16 non-error direct returns; the fix is arm-agnostic and inert on the 15 that never park.

Measured on the path you named (CopyTargetChoice → NamedChoice → TributeChoice, Soul Warden on board):

Soul Warden delta ledgers emitted pair
previous head bee984f80 0 correct correct
35f71f0d8 1 correct correct

The regression at crates/engine/tests/integration/token_zone_change_index.rs now asserts 1, and it also pins "OrderTriggers(2)" — the (2) is what makes it discriminating, because it proves Fanatic's own CR 603.4 intervening-if trigger fired alongside the observer rather than instead of it. Per CR 603.3b both are their controller's and a first-pass ordering prompt is required there, so that prompt is asserted, not papered over. Every other realization site was re-measured unchanged (Painter +1, Watchdog +1, Bears unchanged).

One residual I want on the record rather than discovered later: the pipeline runs from event_start = scan_from, so the direct-return handler's own events (from effects::tribute::apply_paid, and from resume_pending_continuation_if_priority, which can resolve a whole ability chain) still get no CR 603.2 scan — before or after this change. Widening to event_start = 0 is not safe: collect_and_drain_observer_triggers_if_settled never writes consumed_before_priority_trigger_events, and the exclusion filter covers ZoneChanged only, so every non-ZoneChanged observer event it already collected would be collected a second time. Narrow was the lesser defect; happy to take direction if you'd rather I close it properly.

🔴 Required evidence — the sticky could not identify the head, and regenerating cannot fix it

I could not satisfy this by regenerating, and the reason is a defect rather than a stale run. Comment 5153742352 did re-run for bee984f80 — its updated_at is 2026-08-02T06:57:35Z. The problem is upstream of the workflow:

  • The body is generated by render_markdown in crates/engine/src/bin/coverage_parse_diff.rs.
  • Its empty-case early return emits the marker plus ✓ No card-parse changes detected. and nothing else — no base SHA, no head SHA. The live comment body is byte-identical to that.
  • The non-empty branch prints only the baseline.
  • So neither branch has ever emitted a head SHA, on any PR. Re-running CI reproduces the same SHA-less text indefinitely.

That generator is ordinary crate source, not a workflow, so I fixed it there: both branches now carry the head, sourced from the HEAD_SHA the CI step already exports (ci.yml:407-410) with a --head-sha flag mirroring the existing --base-sha. Notably a git rev-parse HEAD fallback would have been wrong, not merely inelegant — that job checks out the synthetic PR merge commit at fetch-depth: 2, so git would report the merge SHA, never pull_request.head.sha. The full 40-character SHA is emitted so the identity check you're running matches literally.

Two constraints shaped the wording, both pinned by assertions with revert-probes: scripts/pr_review.py requires the marker to remain the first line, and it classifies sticky state by the substrings "Baseline pending" and "signature(s)" — so the head line must contain neither, or a no-change sticky would misclassify as real changes.

One consequence worth stating so it doesn't cost you a round: the sticky names the head it was generated from, so landing these commits necessarily moves it. The regenerated comment will identify 35f71f0d8, not bee984f80 — a literal identity check against bee984f80… is unsatisfiable by construction, since any commit that fixes the generator changes the head. The substantive requirement, that the artifact be bound to a head, is met.

This is a class-wide provenance fix rather than a #6851 one, so it is disclosed under Scope Expansion. Two residuals remain that I deliberately did not touch, because they live in .github/workflows/**, which I treat as a hard stop. Offering them as text for you or a maintainer to apply:

  1. ci.yml:453 — the baseline-pending fallback body names the base only. Adding head `%s` (the $HEAD_SHA already dereferenced at :425/:429) would make that branch head-bound too.
  2. ci.yml:436-439 — when the engine-source hash is unchanged the step sets produced=false, so no artifact is uploaded and the sticky silently retains its previous head's body. No change to the generator can make a comment that was never regenerated head-bound; the fix is to emit a minimal head-bound artifact on that path instead of skipping.

Credit to CodeRabbit for the CR 514.2 citation correction on the per-turn reset in the earlier round.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/bin/coverage_parse_diff.rs`:
- Line 347: Update the argument parsing branch for --head-sha so a present
option with no following value returns a usage error instead of reusing the
existing head_sha fallback; preserve accepting and assigning a supplied value.
Add a regression test covering trailing --head-sha and assert that parsing fails
with the expected usage error.
- Line 445: Update the DiffReport data model to include the head_sha provenance
field, populate it when constructing the report in the coverage diff parser, and
extend the associated tests and expected parse-diff.json output to verify the
field is serialized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bef5dcf4-b52c-450e-940a-9a54d0e2e72b

📥 Commits

Reviewing files that changed from the base of the PR and between bee984f and 35f71f0.

⛔ Files ignored due to path filters (1)
  • crates/engine/tests/fixtures/cr733/authority_matrix.json.gz is excluded by !**/*.gz
📒 Files selected for processing (10)
  • crates/engine/src/bin/coverage_parse_diff.rs
  • crates/engine/src/game/effects/counters.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/elimination.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_replacement.rs
  • crates/engine/src/game/scenario_db.rs
  • crates/engine/src/game/turns.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/tests/integration/token_zone_change_index.rs
🚧 Files skipped from review as they are similar to previous changes (9)
  • crates/engine/src/game/scenario_db.rs
  • crates/engine/src/game/turns.rs
  • crates/engine/src/game/elimination.rs
  • crates/engine/src/game/effects/counters.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/game/engine_replacement.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/tests/integration/token_zone_change_index.rs
  • crates/engine/src/game/effects/token.rs

Comment thread crates/engine/src/bin/coverage_parse_diff.rs Outdated
Comment thread crates/engine/src/bin/coverage_parse_diff.rs Outdated
Two review findings from CodeRabbit on the head-SHA change.

A present-but-valueless `--base-sha` or `--head-sha` silently fell back to
the previous value, so a report could name a commit it does not describe.
Both now return a usage error. The pre-existing `--base-sha` line is fixed
alongside the one this branch added: leaving it lenient would make two flags
in the same provenance category behave differently. `--markdown`, `--json`
and `--max-clusters` stay lenient by design and a test pins that asymmetry
as deliberate — a missing value there omits output the caller can see, so
there is nothing to misattribute.

`parse-diff.json` carried no provenance at all, while both the Markdown
report and the sticky comment direct a reader with truncated output to open
it. `DiffReport` now carries `head_sha` and `base_sha`, ordered as the
Markdown presents them. Nothing in the repo deserializes that artifact — the
four references to it are two workflow write/upload paths and two prose
mentions — so the added keys cannot break a consumer.

Neither defect is reachable through CI, which never passes `--head-sha` and
always pairs its flags with values; this hardens the local and manual
surface.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

@coderabbitai — both taken, in bc9f94455. Thanks; the second one in particular found a real gap I had left.

Missing --head-sha value (Major) — fixed, and widened. Both --base-sha and --head-sha now return a usage error instead of falling back. I fixed the pre-existing --base-sha line alongside the one this branch added: hardening only the new flag would leave two flags in the same provenance category on different rules, which is the sibling-inconsistency this repo's guidelines call out. --markdown, --json and --max-clusters stay lenient, and a test now pins that asymmetry as deliberate rather than leaving it to look like an oversight — a missing value there omits output the caller can see, so there is nothing to misattribute.

One correction on the impact, offered as measurement rather than disagreement about the fix: it is not reachable from CI. ci.yml never passes --head-sha at all — the head arrives via the HEAD_SHA env var — and it passes --base-sha "$BASE_SHA" quoted and non-terminal, so args.next() cannot return None on either flag through that path. This is hardening of the local and manual surface, which is worth doing (a wrong SHA is worse than a missing one), but it should not be recorded as a CI bug fix.

Provenance in parse-diff.json (Minor) — fixed, and symmetrised. You are right that an artifact advertised as the drill-down for a comment it cannot identify has the same defect the head SHA was added to fix. DiffReport now carries head_sha and base_sha, ordered as the Markdown presents them — I added the base as well, since shipping JSON with only the head would have introduced a fresh asymmetry against the report. I checked every reference to parse-diff.json in the repo before changing the schema: four hits, none of which parse the file (two workflow write/upload paths, two prose mentions), and pr_review.py matches only the sticky comment marker, never the JSON. So added keys cannot break a consumer.

Verification: three new tests (bin suite 9 → 12), each with a revert-probe that was run and restored byte-identically. The JSON probe swaps head and base rather than deleting a key, so it fails on a symmetric mistake and not merely a missing field. Full suite 18268 + 12 + 9 + 4374 + 0 = 22663 passed / 0 failed; fmt, check --workspace --all-targets and clippy --workspace --all-targets -D warnings all clean.

One related weakness I found while doing this and deliberately did not fix: all five flags still greedily consume the next token, so --base-sha --markdown out.md would set the base SHA to "--markdown". Strictness on missing values does not close that. It is likewise unreachable in CI, and guarding it means a starts_with("--") check that is speculative today — flagging it here rather than expanding this PR's scope.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline bc9f94455
printf '\n--- changed coverage tool diff ---\n'
git show --format=fuller --unified=10 bc9f94455 -- crates/engine/src/bin/coverage_parse_diff.rs

printf '\n--- source outline ---\n'
ast-grep outline crates/engine/src/bin/coverage_parse_diff.rs --items all

printf '\n--- option parsing and provenance contexts ---\n'
rg -n -C 5 -- '--base-sha|--head-sha|struct DiffReport|head_sha|base_sha|missing.*value|usage' \
  crates/engine/src/bin/coverage_parse_diff.rs

printf '\n--- related file references ---\n'
rg -n -C 3 'parse-diff\.json|DiffReport|head_sha|base_sha' \
  crates/engine/src/bin/coverage_parse_diff.rs scripts .github 2>/dev/null || true

Length of output: 81965


@lgray Verified bc9f94455.

The Major finding is addressed. parse_args now rejects a valueless --base-sha or --head-sha with a flag-specific usage error. The test covers both flags, valid explicit values, and the CI-compatible HEAD_SHA default.

The Minor finding is addressed. DiffReport now serializes head_sha and base_sha, and json_report_carries_both_shas verifies both fields with distinct values.

Your CI-scope correction is accurate. The change hardens manual and local invocation. The remaining greedy-next-token case is separate from these two findings.

🐇


✏️ Learnings added
Learnt from: lgray
URL: https://github.com/phase-rs/phase/pull/6851

Timestamp: 2026-08-02T10:46:23.283Z
Learning: In `crates/engine/src/bin/coverage_parse_diff.rs`, `--base-sha` and `--head-sha` are provenance options. A present but valueless form must return a usage error rather than silently use a default SHA. CI supplies the head SHA through the `HEAD_SHA` environment variable and invokes `--base-sha` with a value.

Learnt from: lgray
URL: https://github.com/phase-rs/phase/pull/6851

Timestamp: 2026-08-02T10:46:23.283Z
Learning: The `parse-diff.json` artifact produced by `crates/engine/src/bin/coverage_parse_diff.rs` must serialize both `head_sha` and `base_sha`, because the Markdown sticky comment can direct users to that artifact when its report is truncated.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/bin/coverage_parse_diff.rs`:
- Around line 365-366: Update the argument parsing branches for --base-sha and
--head-sha to reject missing, empty, and values beginning with "--" instead of
accepting them as SHA strings. Preserve valid SHA parsing, and add tests
covering empty and option-token values for both flags.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8643ffe7-4c30-421c-b441-2bf6b0c57ff3

📥 Commits

Reviewing files that changed from the base of the PR and between 35f71f0 and bc9f944.

📒 Files selected for processing (1)
  • crates/engine/src/bin/coverage_parse_diff.rs

Comment thread crates/engine/src/bin/coverage_parse_diff.rs Outdated
Co-authored-by: Lindsey Gray <lindsey.gray@gmail.com>
@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold for current head 7396e76b699018134a98167711e76a0ad0e35fc9: the focused fixup re-review has no code findings, but approval/enqueue resumes only after this head's CI settles and the parser/engine parse-diff sticky is regenerated with this head bound to it. The branch is also behind main, so freshness will be rechecked at that point.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved: current head passes required CI, the parse-diff sticky is bound to this head with no card-parse changes, and the previously requested lifecycle/provenance corrections are verified.

@matthewevans
matthewevans added this pull request to the merge queue Aug 2, 2026
@lgray

lgray commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks — and thanks for taking the fixup directly. Your 7396e76b6 supersedes the equivalent change I had staged locally; I synced to your head rather than pushing over it and I'm not touching the branch. Enqueue is yours.

Do not hold the merge for anything below. Two residuals survive your validation. Neither is reachable from ci.yml, which always passes a real SHA, so this is for the record and a possible follow-up — not a re-review request.

Measured on your binary at 7396e76b6, driven over an empty coverage pair so it lands in the no-changes branch — the same one this PR's sticky uses. Your guard correctly rejects "" and "--markdown" (both exit 2, nothing written):

invocation exit rendered provenance line
--head-sha " " 0 _Generated for head ` `._
--head-sha "deadbeef1234" 0 _Generated for head `deadbeef1234`._ (control)
HEAD_SHA="", no flag 0 _Generated for head ._`` ← empty backtick pair
  1. Whitespace-only values pass. !value.is_empty() admits an all-whitespace value, so --head-sha " " stamps the report with ` `. .trim().is_empty() closes it. (-x passes too, though nothing here takes a single-dash option.)

  2. The env path bypasses the guard entirely. HEAD_SHA arrives as head_sha_default and never meets it. std::env::var returns Ok("") for a set-but-empty variable, so it misses main's unwrap_or_else fallback as well — row 3 against the unknown an unset var yields is itself the proof, since Err(NotPresent) would render unknown in both. It's the only CI-shaped route to a sticky misreporting its own provenance, and an empty backtick pair reads as a rendering bug where unknown reads honestly as absent.

If you want them, I have both as a tested patch (2 tests, revert-probes run and restored byte-identically) and will open it as a separate PR against main after this lands — normalizing blank-or-whitespace to unknown at the env default only, deliberately keeping the flag path a hard error: someone who typed --head-sha named a specific commit and deserves a usage error, whereas a blank env var shouldn't fail a CI step over provenance. Equally happy to drop it.

Merged via the queue into phase-rs:main with commit 96e41b3 Aug 2, 2026
13 checks passed
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…nto phase-rs#6851

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

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

A sixth producer remains a counted event.

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

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

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

A sixth producer remains a counted event.

Assisted-by: ClaudeCode:claude-opus-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants