fix(engine): the loop period the engine routes on belongs to a seat — name it, everywhere - #1
Closed
lgray wants to merge 5 commits into
Closed
fix(engine): the loop period the engine routes on belongs to a seat — name it, everywhere#1lgray wants to merge 5 commits into
lgray wants to merge 5 commits into
Conversation
…ere the engine routes on it
`last_loop_action_sequence` records ONE seat's driving period, but every site that
routed on it tested only `!is_empty()` — while the consumer those sites route to,
`try_offer_object_growth_shortcut`, already required every step to belong to the
priority holder. The routing signal was strictly coarser than the admission predicate
of the thing it routes to, so a foreign period could not produce an object-growth offer
(CR 732.2a) yet still refused the bounded one. One opponent activation therefore refused
a proposer's own certified bounded offer for the rest of the game — measured on the
user's Dina/Bloodthirsty Conqueror capture, where re-injecting the dump's own recorded
step (`Activate{source_id:268}`, controller 2) turned `LoopShortcut(0)` into
`Priority(2)` for the whole 140-beat drive.
Hoist that whole-period test into `GameState::loop_period_controller() -> Option<PlayerId>`
(typed, `None` for no period and fail-closed `None` for a heterogeneous run) and read it
at all five routing sites, so signal and consumer cannot disagree by construction:
* the empty-stack Interactive bridge precondition (behaviour-preserving — the consumer
already returns `None` for a foreign period; consistency only),
* the bounded mint's conjunct (1b) — the bug,
* `materialize_fixed_shortcut`'s object-growth dispatch, mandatory in lockstep: relaxing
(1b) alone lets a bounded offer be accepted with a foreign sequence in state and then
commits ZERO cycles, the exact silent misroute (1b) exists to prevent,
* `apply_until_lethal_shortcut`'s drive dispatch — the same hazard on the `UntilLethal`
route, and PRE-EXISTING: Path A never reads the sequence, so a Path-A offer accepted
with a foreign period already drove the wrong seat,
* `handle_declare_shortcut`'s `template: None` arm, the strictest constraint of the five:
a merely-non-empty test would let a foreign period take the sibling arm that performs
zero pin validation and open the APNAP window on a client-supplied declaration.
`try_offer_object_growth_shortcut`'s own `any(|c| c.controller != caster)` now calls the
same method rather than restating it.
Scope the certification window's cast-set to the bound proposer (`window_cast_card_ids`,
gate (5)) so a foreign period cannot relieve a soundness gate: without it, an opponent's
choice of WHICH CARD to activate selects which relief applies to the proposer's
certification. `is_some_and`, not `is_some` — the proposer-less 2-arg cover entry that
`analysis::loop_check` uses stays byte-identical.
Correct two docs this change falsifies: the load migration's "the only consumer is the
live detection re-drive, so dropping is strictly safe" (four other consumers, none a
re-drive — that false premise is why the signal went un-audited), and the basis-B
reasoning block's claim that gate (5)'s relief cannot fire "because (1b) requires the
sequence to be EMPTY" (conclusion survives, reason is now the proposer scoping).
Re-pin the CR 603.5 prompt census entry `engine.rs:11549` -> `:11619`: +70 lines above
it, producer line sha256 `8a544e878d3e77fb` and its 40-line block `a5d40248d9015329`
identical on both sides, still inside `begin_pending_trigger_target_selection`, other
four entries in byte-untouched files and unmoved.
Assisted-by: ClaudeCode:claude-opus-5
…drain fixture `migrate_transient_loop_sequence` clears `last_loop_action_sequence` at every load whose `waiting_for` is not a shortcut window, so no dump-driven row has ever STARTED from a board carrying one. The whole accept-side dispatch on that field was untested — the blindness is in the fixture pipeline, not in the rows, and the answer is injection into a tracked fixture rather than a new tracked dump. Two additions, on two different seams: * `an_accepted_bounded_grant_drains_even_with_a_foreign_period_in_state` — inject an opponent's period into the tracked 4p Dina/Conqueror fixture at the offer beat, accept `Fixed(3)`, and assert the committed life delta is exactly `3 x` the published per-period delta, i.e. the DRAIN materializer ran rather than the object-growth one. Asserts that dina's schema publishes no points, so the declare-seam arm is provably not on this path and the outcome attributes to the materialize dispatch alone. * `user_capture_probes.rs` — the Dina user-capture arms, env-gated on `DINA_DUMP` and inert without it, so the capture that FOUND this defect stays re-runnable against the tree that fixed it. ARM D1 loads the dump as production does (field cleared); ARM D2 re-injects the dump's own recorded step and asserts the fix bar against D1 re-driven in the same process rather than against transcribed numbers. Measured on the user's capture with the seat-relative routing in place: the two arms are identical on every recorded axis — `fired=Some(21) wf=LoopShortcut(0) ring=5 life=[51,29,34,34]` — while `seq=1` still holds the foreign step. Before the fix the live arm answered `Priority(2)` for all 140 beats. Assisted-by: ClaudeCode:claude-opus-5
…p period Round-2 review fixes on the seat-relative loop-period authority. One behavioural regression this branch's own fix enabled, two naming contracts it falsified, and four measured-false or vacuous artifacts. THE REGRESSION (a SEVENTH routing site the plan's census missed). The plan enumerated every site that ROUTES ON `last_loop_action_sequence` and none that WRITES OR CLEARS it. `handle_decline_shortcut` cleared the field unconditionally. Pre-fix that was harmless by construction: no `WaitingFor::LoopShortcut` could exist with a foreign period, so the handler only ever cleared the proposer's own. Post-fix a bounded offer CAN be minted with a foreign period, and `DeclineShortcut` dispatches from any `LoopShortcut` — it is the AI's only action at a bounded offer — so one seat declining a drain offer wiped another seat's accumulating object-growth period and suppressed that seat's own offer until it re-armed. Self-healing and not rules-wrong, but real. The clear is now scoped to `loop_period_controller() == Some(proposer)`, with `proposer` threaded in from the dispatch arm; scoping costs the suppression nothing, because `try_offer_object_growth_shortcut` already returns `None` for every period that is not the priority holder's, so the only period whose survival could re-fire it is the one this branch still clears. The handler's doc — which still described a `!is_empty()` gate that no longer exists — is corrected in the same hunk. BOTH CENSUSES, re-taken by hand rather than inherited. Predicate: every `last_loop_action_sequence` occurrence under `crates/*/src`, classified PROD/TEST by brace-tracked `#[cfg(test)]` item extents (a first-hit line heuristic is invalid — `engine.rs` carries 18 column-0 `#[cfg(test)]` attributes). * READ/ROUTE: seven production sites read the accessor (bridge precondition, mint (1b), the two drive dispatches, `try_offer_object_growth_shortcut` itself, the declare arm, and `window_cast_card_ids`). * WRITE/CLEAR: fourteen production mutations in two classes. ACCUMULATION (the field records the most recent driving seat's period; a controller change legitimately resets it — that IS the design): `accumulate_loop_action_step`, `record_mana_loop_action_step`, `record_loop_pin`, `apply_action`'s activate arm, and the recast capture in `casting_costs.rs`. TEARDOWN (a clear as a consequence of a shortcut-protocol event — the class where a foreign period can now be present): `until_lethal_fallback`, `materialize_object_growth_shortcut`, `handle_decline_shortcut`, and the load migration. Of those, `materialize_object_growth_shortcut` is ownership-correct BY CONSTRUCTION (only reachable through site C's gate) and the load migration has no seat semantics. `until_lethal_fallback` IS an unconditional cross-seat clear, but its reachability is IDENTICAL before and after this branch: `handle_declare_shortcut` rejects `UntilLethal` against a bounded offer, so the only route in is Path A, which never read the sequence pre-fix either. It is the already-disclosed site-D Path-A hazard, not a regression this fix enabled, and no fixture reaches it — reported, deliberately not patched blind. TWO NAMING CONTRACTS THIS BRANCH FALSIFIED — renamed, not annotated. * `BoundedOfferRefusal::DrivingSequenceNotEmpty` -> `ProposerHasDrivingPeriod`. The conjunct now refuses only for the proposer's OWN period; the old name was the contract roughly nine rows assert by. Blast radius measured before renaming: 12 tracked references in three files, all inside `crates/engine`; the enum derives no `Serialize` and appears in no TypeScript, WASM, or JSON surface. * `a_nonempty_action_sequence_mints_no_bounded_offer` -> `a_proposers_own_driving_period_mints_no_bounded_offer`. Its name and doc asserted a general property that is false at this tree; it passed only because its arm happens to use `controller: proposer`. It sat directly above a row asserting the negation, so the file presented two adjacent rows claiming contradictory general properties. A third falsified doc, found while renaming: `try_offer_bounded_cycle_shortcut`'s own summary still called the (1b) discriminant "an EMPTY `last_loop_action_sequence`". ALSO CORRECTED. * `migrate_transient_loop_sequence`'s doc exists to fix an undercount and had a smaller one: it named five consumers and omitted the bridge precondition and the certification window's cast-set scoping. Seven, enumerated from the accessor's own call set. * `resource.rs` X4-5's rustdoc named the wrong mutation for its arm (1). MEASURED: the `is_some`-instead-of-`is_some_and` swap fails arm (2), not (1) — with no bound proposer it never returns early. The mutation that fails (1) is the unconditional-match form. * `probe_dina_with_live_sequence` had no in-row reach guard, so on a board where neither side reaches an offer both sides compare equal and the row passed vacuously. Its sibling had one; this one now has its own, on the control it re-drives in-process. * `window_scope_from_cover_frames`'s `sole_driver` was an exact two-frame twin of the new authority and was not consolidated. It now reads `loop_period_controller()` per frame and requires the two answers to agree — the change's whole thesis is one authority. THE CONTROL ARM NO EXISTING FIXTURE COULD SUPPLY, because the pre-fix world could not express the state: `declining_a_shortcut_discards_only_the_decliners_own_driving_period` drives the tracked 4p Dina/Conqueror capture to its real bounded offer and declines it through the production `apply()` reducer with a period injected for a DIFFERENT seat (FOREIGN, must survive) and for the proposer (OWN, must be cleared). DROP the ownership test and FOREIGN fails while OWN passes; TRIVIALIZE it to never clear and OWN fails while FOREIGN passes — each direction flips a different named assertion, so no constant implementation passes. CORRECTION TO THIS BRANCH'S EARLIER COMMIT MESSAGES. They claim the pre-fix live arm answered `Priority(2)` "for all 140 beats". That is wrong on the distribution and right only on the endpoint. Re-measured by restoring step (1b)'s `!is_empty()` form on this tree and re-running the capture (nothing downstream of (1b) can change a per-beat verdict while (1b) refuses, so the pre-fix trajectory is reproduced faithfully): over the 140 driven beats the destinations are `31 Priority(0) + 31 Priority(1) + 32 Priority(2) + 31 Priority(3) + 15 OrderTriggers`, and the arm ENDS at `fired=None wf=Priority(2) ring=16 seq=1 life=[64,16,21,21]` without ever firing. The load-bearing figure is the census — `125 step-(1b) refusals + 15 NotAtPriority` — not the seat label. Re-pin the CR 603.5 prompt census entry `engine.rs:11619` -> `:11644`: +25 lines above it, producer line sha256 `8a544e878d3e77fb` and its 40-line block `a5d40248d9015329` identical on both sides, still inside `begin_pending_trigger_target_selection` (which moved by the same +25), and the other four entries' files byte-untouched. Assisted-by: ClaudeCode:claude-opus-5
…wn, everywhere — and the capture that proves it is tracked
Round-3 conversion of the round-2 DISCLOSURES into fixes, under the policy that an
"out-of-scope follow-up" is not exempt from the no-deferrals mandate. Three of the four are
closed; the fourth's honest exit turned out to be a RETRACTION rather than a better figure.
A fifth site — the one round 2 named and deliberately did not patch — proved reachable by
exactly the technique that reached its sibling, so it is fixed here instead of queued.
THE HEADLINE RESULT NOW RUNS IN CI. The end-to-end claim — the bounded offer firing on the
user's own board with a FOREIGN driving period live in state — rested entirely on an
env-gated probe over an untracked bug-report attachment, so nobody outside this lane could
reproduce it. The capture is now a tracked fixture,
`crates/engine/tests/fixtures/dina_conqueror_phase5_no_offer_4p.json.gz` (771 577 B, sha256
`ec1521d96ecd4817…`), derived by the lane's own recipe `jq -c '{gameState}' <dump> |
gzip -9 -n` from the PRISTINE root archive `dina-conqueror-phase5-no-offer.zip` (sha256
`f85f3cd4ec9f8b5f…`, matching that root's own `MANIFEST.sha256`; member
`game-state-turn-5-2026-08-03T19-29-36-888Z.json`, 20 610 078 B, sha256 `87266bbed39c1b41…`).
Re-deriving reproduces the archive byte-for-byte, which is the whole point of `-n`. The
`DINA_DUMP` gate is DELETED rather than kept alongside: it buys nothing the fixture does not.
Tracking the dump alone would NOT have reproduced the result, and that is the interesting
part. `migrate_transient_loop_sequence` clears `last_loop_action_sequence` at every load that
is not a shortcut window, so `into_game_state()` drops the one field this whole file is about
no matter where the bytes came from — a naive tracked row is ARM D1 twice. ARM D2 reads the
field back out of the fixture's OWN serialized JSON and restores it, and ARM D1 now ASSERTS
both halves of that asymmetry (the fixture still serializes exactly one step; the loaded board
carries none), so a future re-derivation that loses the field reds instead of silently
degrading into a second control. The tracked rows reproduce the env-gated figures cell for
cell: DINA-LOADED, DINA-LIVE and DINA-CONTROL all end `fired=Some(21) ring=5
life=[51,29,34,34]`, with `seq` 0 / 1 / 0.
THE RESIDUAL MINT-CENSUS FIGURE IS RETRACTED, and the instrument that would have caught it is
now in the row. Round 2 published `5 ProposerHasDrivingPeriod` residual refusals on the live
arm as the guard "working exactly as designed". The mint runs (1b) BEFORE (2), so that
attribution is evidence only where the same frame with the field EMPTY would have offered —
and it would not have. `mint_verdict` is now taken twice per frame: once on the board as
driven, once on the identical board with only `last_loop_action_sequence` emptied. MEASURED on
this tree, the live arm's 21 census frames are `4 NoCertification + 3 NotAtPriority +
5 ProposerHasDrivingPeriod + 9 ProposerIsNotActivePlayer`, and all five (1b) refusals read
`cleared=ProposerIsNotActivePlayer` ⇒ **0 LOAD-BEARING + 5 DOMINATED**. The residual count was
worth nothing and is withdrawn. What replaces it are two assertions the endpoint equality
could not state, both over that paired instrument:
* FOREIGN-INERTNESS — at each of the 13 frames whose recorded period belongs to a seat OTHER
than the proposer, the live verdict must EQUAL the cleared verdict. Inertness frame by
frame, not only at the endpoint.
* OWN-PERIOD — at each of the 5 frames where the period IS the proposer's, (1b) must refuse.
The load-bearing half of the guard, asserted directly instead of inferred from a residual.
They are ordered before the endpoint bar deliberately: under the DROP mutant the endpoint bar
would otherwise panic first and leave FOREIGN-INERTNESS unobserved.
CR 732.3 IS NOT WHAT STEP (1b) EVER ENFORCED — stated here, not only in a comment. CR 732.3
verbatim: "Sometimes a loop can be fragmented, meaning that each player involved in the loop
performs an independent action that results in the same game state being reached multiple
times. If that happens, the active player (or, if the active player is not involved in the
loop, the first player in turn order who is involved) must then make a different game choice
so the loop does not continue." The engine implements no CR 732.3 gate anywhere, and the old
`!is_empty()` form was not an accidental one either. MEASURED at the three arming sites,
`last_loop_action_sequence` is populated ONLY by mana activations
(`record_mana_loop_action_step`), by activations whose ability graph contains an
`Effect::Token` (`apply_action`'s activate arm, which CLEARS for every other lone activation),
and by buyback-paid token-creating recasts (`casting_costs.rs`, gated on `additional_cost_paid
&& has_buyback && is_token_creating`). CR 732.3's own example — one player granting flying,
another removing it — arms none of the three, so the textbook fragmented loop never populated
the field and was never suppressed. The removal is real; its reachability is narrow, and that
limit belongs beside the claim.
THE TWO ROW-LESS SITES, RE-TESTED RATHER THAN RE-ASSERTED.
* SITE D (`apply_until_lethal_shortcut`'s drive dispatch) was reported unreachable by any
fixture. That was a fact about the corpus, not about the engine. Site D needs an
`UntilLethal` proposal, which `handle_declare_shortcut` rejects against any bounded offer, so
the route in is a Path-A offer — and `interactive_3p_optional_cascade_apnap_accept_win`
raises one. Injecting an opponent's period at that accept REACHES the branch and
DISCRIMINATES: shipped, the drive crowns (`GameOver { winner: Some(P0) }`) with the foreign
period still length 1; with the seat test dropped back to `!is_empty()`, it takes the
object-growth branch, falls through to `until_lethal_fallback`, ends at `Priority { P0 }`,
and the foreign period comes back length 0. It has a row.
* SITE F (`handle_declare_shortcut`'s `template: None` arm) sits under
`!offer.schema.points.is_empty()`. Re-tested against the new fixture: its offer publishes
`points = 0`, `is_bounded = true`, `max_iterations = 28`, `predicted_winner = None`, so this
capture can host neither site F nor site D. That is now three assertions in ARM D1 instead of
a scope claim in prose — a future capture that DOES publish points reds the line rather than
quietly staling its siblings' scope arguments.
THE SITE ROUND 2 NAMED AND REFUSED IS FIXED, not queued. `until_lethal_fallback` is the SECOND
unconditional cross-seat clear in the teardown family; round 2 found it and declined to patch
it blind on the same "no fixture reaches it" ground. It opens with `*state = committed`,
restoring the PRE-DRIVE board — which, since step (1b) went seat-relative, can carry another
seat's period. MEASURED on the shipped tree before this guard: with a foreign period injected
at a sprout-swarm `UntilLethal` accept, the drive aborts, the fallback runs, and the foreign
seat's period comes back length 0. The clear is now scoped to `loop_period_controller() ==
Some(proposer)`, with `proposer` threaded in from the proposal at all five call sites — the
same authority, the same one-line shape and the same argument as the decline seam: scoping
costs the suppression nothing, because `try_offer_object_growth_shortcut` returns `None` for
every period that is not the priority holder's, so the only period whose survival could
re-fire the offer this fallback walks away from is the proposer's own, which the branch still
clears. The `loop_detect_ring` clear stays unconditional — it is a board-wide sampler with no
seat semantics.
A REALIZED NEGATIVE, recorded rather than hidden. The complementary constant at site D —
always take the drain branch — was measured against the WHOLE integration suite at this tree
and **4564 rows passed, 0 failed**. Site D's own-period branch is asserted by no row in this
tree. That is a pre-existing gap this change neither creates nor closes: both branches end at
the same `Priority` handback because `until_lethal_fallback` rolls the board back to
`committed`, so they are observationally identical on every fixture that reaches them.
MUTANT PAIRS (source-swapped arms; every arm's test-binary CONTENT sha256 DIFFERS, so no arm
silently reused a stale build. Each filter is a FULL libtest path under `--exact`, and the
matching PRISTINE arm reports `1 passed` for it — the positive control against a filter that
matches nothing and lets libtest exit 0. Reverts are content writes, never `git restore` or
`git stash`; `engine.rs` is byte-identical to the committed tree afterwards.)
| arm | mutation | row that flips | outcome | binary sha256 / size |
|---|---|---|---|---|
| PRISTINE | — | all three | 1 passed each | `a25178b4a94783db` / 382 928 928 |
| B_DROP | (1b) back to `!is_empty()` | ARM D2 | FAILS **FOREIGN-INERTNESS** — 93 of 93 foreign frames diverged | `b3d03fe9de495704` / 382 928 864 |
| B_TRIV | (1b) deleted (never refuse) | ARM D2 | FAILS **OWN-PERIOD** — 5 of 5 own frames answered `ProposerIsNotActivePlayer` | `a92e3fa6af0cd64b` / 382 928 664 |
| D_DROP | site D back to `!is_empty()` | site-D row | FAILS **SITE D** — `Priority { P0 }` where `GameOver { Some(P0) }` was required | `7f13de0b89b9370c` / 382 928 816 |
| D_TRIV | site D constant `true` | `interactive_3p_optional_cascade_apnap_accept_win` | FAILS — engine index panic driving an EMPTY sequence | `2c6272dd913c4ebb` / 382 923 304 |
| ULF_PRISTINE | — | fallback row | 1 passed | `ac732e4df0865206` / 382 968 032 |
| ULF_DROP | fallback clear unconditional | fallback row | FAILS the **FOREIGN** arm — `[]` where `[PlayerId(1)]` was required | `9079f83de4031027` / 382 967 816 |
| ULF_TRIV | fallback clear deleted | fallback row | FAILS the **OWN** arm — the proposer's own period survives its own abort | `fb1d43209f60d252` / 382 967 776 |
B_DROP/B_TRIV flip DIFFERENT named assertions, as do ULF_DROP/ULF_TRIV, so no constant
implementation of either guard passes. HONEST PARTIAL: under B_DROP the OWN-PERIOD assertion
is not directly observed (FOREIGN-INERTNESS panics first); structurally it cannot fail there,
because an unconditional (1b) refuses at every own frame too.
GATES at this tree (`cargo fmt --all -- --check` 0; `cargo clippy --workspace --all-targets --
-D warnings` 0): `cargo test -p phase-engine --lib` **18523 passed / 0 failed / 6 ignored**;
`cargo test -p phase-engine --test integration` **4564 passed / 0 failed / 2 ignored**.
Re-pin the CR 603.5 prompt census entry `engine.rs:11644` -> `:11662`, **+18** measured from
the `@@` hunks whose OLD start is above the pin (`+14` on the fallback's doc and new parameter,
`+2` and `+2` on its two clears; the five call-site hunks are line-for-line). Identity
re-established at the new coordinate by two independent instruments: the producer line is
sha256 `8a544e878d3e77fb` on both sides and occurs EXACTLY ONCE in the file, its 40-line block
is `a5d40248d9015329` on both sides, and it is still inside
`begin_pending_trigger_target_selection` (which moved `:11495` -> `:11513`, the same +18).
`git diff --stat` on `effects/mod.rs` and `effects/scoped_library_search.rs` is empty, so the
other four entries could not have moved.
Assisted-by: ClaudeCode:claude-opus-5
The rebase onto `fa5fbdfd7` conflicted in the census-producer array on all three of this branch's commits — each side carried a coordinate measured against a base that is now history. Resolving those conflicts by taking either side would have shipped a pin that names the wrong line. The pin is re-derived at the rebased tip instead, and located by CONTENT: the producer's sha256 `8a544e878d3e77fb` is unique in the file, at `:11696`. Uniqueness of the hash is the evidence that it is the same producer; the +113 shift is only a description of where it landed. Three-way conflict resolution is not a line-shift, so a sum-of-hunks figure would have been the wrong instrument here whatever it summed to. Enclosing fn unchanged (`begin_pending_trigger_target_selection`, :11547). Set preservation: `effects/mod.rs` and `effects/scoped_library_search.rs` are byte-untouched by this branch, so the other four entries could not have moved. Assisted-by: ClaudeCode:claude-opus-5
Owner
Author
|
🤖 AI text below 🤖 Opened against the fork by mistake — the correct target is |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 AI text below 🤖
Summary
A bounded loop-shortcut win offer never fired on the user's 4-player Dina / Bloodthirsty Conqueror capture. The engine routed on
last_loop_action_sequenceas a bare "is something driving" flag at every seam that consumes it, so any seat's recorded period suppressed every seat's offer — an opponent's unrelated Currency Converter activation was enough to make the proposer's own drain loop unofferable for the rest of the game.This names the seat a recorded period belongs to and routes on that instead, through one accessor rather than six restatements. The same question turned out to have a second half: three teardown sites cleared the field without asking whose period it was. Post-fix that is a live regression — a bounded offer can now be minted while a foreign period is live, so one seat's decline or aborted drive would wipe another seat's accumulating period. All three are ownership-scoped here.
Files changed
crates/engine/src/game/engine.rs— the routing sites behind oneloop_period_controller()authority;handle_decline_shortcutanduntil_lethal_fallbackclears ownership-scoped,proposerthreaded from their dispatches;BoundedOfferRefusal::DrivingSequenceNotEmpty→ProposerHasDrivingPeriod; CR 603.5 census pin re-derivedcrates/engine/src/analysis/resource.rs—sole_driverfolded onto the same authoritycrates/engine/src/types/game_state.rs—migrate_transient_loop_sequencedoc: consumer count corrected 5 → 7crates/engine/tests/integration/user_capture_probes.rs— new: the user's capture driven through productionapply(), fixture-backedcrates/engine/tests/integration/loop_shortcut.rs— the decline seam, the aborted-drive seam, and the site-D reachability rowcrates/engine/tests/integration/fantastic_four_bounded_loop.rs— the accept-side rows the load migration hid from every drain fixturecrates/engine/tests/fixtures/dina_conqueror_phase5_no_offer_4p.json.gz— new, 771,577 B: the user capture this PR exists to fixcrates/engine/tests/integration/main.rs— module registrationFive commits, +1691/−91, 9 test rows added and 0 removed.
Track
Developer
LLM
Model: claude-opus-5
Tier: Frontier
Thinking: high
Implementation method (required)
Method: /engine-implementer
CR references
CR 732.2a(a shortcut is suggested by the player with priority, describing choices for all players — the basis for asking whose period is driving rather than whether one exists),CR 732.2b(the APNAP acceptance window),CR 732.3(fragmented loops — cited as contrast, not support),CR 603.5(the optional-trigger census this diff moves a pin in). Each verified verbatim against the rules text before use.Verification
Measured at
a5ba05dd42cca693405b2ce11a107cc20dcac96b, basefa5fbdfd7059b8cd1f2f2c045c7e79196efc05cc, clean tree (the gate log stampsHEAD_AT_MEASURE,DIRTY_TRACKED=<none>, and the fixture's sha256 as its own first lines, and re-stamps HEAD after the run — both stamps agree, so the figures cannot have drifted onto a different tree mid-run):cargo fmt --all -- --check—EXIT_RC=0cargo clippy --workspace --all-targets -- -D warnings—EXIT_RC=0cargo test -p phase-engine --lib—18527 passed; 0 failed; 6 ignoredcargo test -p phase-engine --test integration—4568 passed; 0 failed; 2 ignoredRow-count movement fully attributed, both signs. This branch adds 1 lib row and 8 integration rows and removes 0 of either. The pre-rebase measurement of the same content read
18523/4564; the rebase moved the merge-base across 8 upstream commits which add 4 lib rows and 4 integration rows and remove 0.18523 + 4 = 18527and4564 + 4 = 4568, exactly. Zero unexplained movement.Worth stating how that was measured, because the obvious way is wrong: the baseline for attribution is
git merge-baseof the pre-rebase tip and main, not whateverupstream/mainpointed at when the earlier figure was taken. Those two differ for any tree that has not just rebased, and using the latter here returns a delta of zero and leaves the real +4 looking like an instrument fault.Discrimination. Every new assertion has a DROP arm and a TRIVIALIZE arm that flip that named assertion, each pair flipping a different one, with every mutant test-binary sha256 distinct from pristine and from each other, and a
1 passedpositive control per run so a mis-filtered mutant cannot report a vacuous pass. Decline seam: DROP (unconditional clear restored) fails the FOREIGN assertion; TRIVIALIZE (never clear) fails the OWN assertion, with FOREIGN reached and passing first. Aborted-drive seam and site D likewise. The offer's own conjunct: reverting it to the seat-blind form fails the live-sequence row while the as-loaded row still passes.Gate A
Gate A PASS head=a5ba05dd42cca693405b2ce11a107cc20dcac96b base=fa5fbdfd7059b8cd1f2f2c045c7e79196efc05cc
Base stated so the range can be checked rather than taken on trust: it is the merge-base with the current
origin/main, the diff over it is 8 files, and 0 of them are undercrates/engine/src/parser. The PASS is real but uninformative for this diff — it passes because no parser code is touched, not because parser code was checked. (An earlier pre-commit run of this gate resolved against a stale fork ref; that figure is deliberately not the one quoted here.)Anchored on
crates/engine/src/game/engine.rs:3002— the pre-existingt.owner != proposal.proposercheck insideapply_confirmed_shortcut(opens:2977): the shortcut seam already scopes a decision to the proposer's own objects rather than to "any". This change extends that same reasoning to the recorded period instead of adding a parallel notion of ownership.crates/engine/src/game/turn_control.rs:354—authorized_submitter_for_player, the pre-existing single authority for "which seat may act for this player". The newloop_period_controller()accessor is the same shape one layer down: one function answering a seat question that call sites previously each answered for themselves.Final review-impl
Stated precisely, because the honest answer is not one line. Independent review passes ran on the first two commits (NON-BLOCKING, nine findings) and drove the two response rounds that followed; the review that found the
handle_decline_shortcutregression is the reason this PR contains it at all. The third and fourth commits were self-verified with per-assertion mutant pairs and by driving both broken routes live through productionapply(), and have not had a separate independent review pass. The fifth (the census-pin re-derivation) is a rebase adaptation, verified by content-hash uniqueness rather than by review.I would rather say that than let one PASS line imply a review that did not happen.
Claimed parse impact
None. No file under
crates/engine/src/parseris touched.Scope Expansion
Two, both disclosed rather than folded in silently — and both are scope this fix created.
The approved plan's site census listed every site that routes on the action sequence and none that writes or clears it. Two teardown sites fell in that blind spot:
handle_decline_shortcutcleared unconditionally. Harmless before this change — noLoopShortcutcould exist with a foreign period in state — and a live regression after it, sinceDeclineShortcutdispatches from anyLoopShortcut.until_lethal_fallbackcleared unconditionally on an aborted drive. This one was first refused as out-of-scope on the argument that no fixture reached it; that argument was corpus-relative, and this PR adds fixtures. Re-tested by injection it proved reachable and discriminating, so it is fixed here rather than deferred.Both censuses — read/route and write/clear — were re-derived as separate lists with stated predicates, rather than patching the sites a review happened to find.
Validation Failures
An earlier revision of this PR carried four disclosures. Three are now fixed and one was refuted; what remains is one pre-existing gap, stated with an owner.
Fixed rather than shipped as disclosures:
DINA_DUMPpath was supplied, so CI could never reproduce it. The capture is now a tracked fixture (sha256ec1521d96ecd481716e48bcb27a58aea05e65913433e5335d28a9010e363441f, byte-reproducible viajq -c '{gameState}' | gzip -9 -nfrom the pristine root, and independently re-derived to the same hash). The env gate is deleted, not kept alongside. Worth naming the obstacle, because it is not obvious:migrate_transient_loop_sequenceclearslast_loop_action_sequenceat every load that is not a shortcut window, so tracking the dump alone yields a second control arm rather than the live one — the rows re-inject the recorded sequence from the fixture's own JSON, exactly as the env-gated probe did. The tracked rows reproduce the env-gated figures cell for cell; nothing was weakened to make them pass.ProposerIsNotActivePlayer⇒ 0 load-bearing, 5 dominated. The figure was worth nothing and is withdrawn in the commit message; two real per-frame assertions replace it.CR 732.3position is stated where it can be read — in the commit message, with its measured reachability limit, rather than only in an in-code comment.Refuted:
UntilLethalaccept and discriminating there: the shipped guard yieldsGameOver{winner: P0}with the foreign period intact, the DROP mutant yieldsPriority{P0}with it wiped. Now rowed. The sibling site F remains unreachable on this corpus, and that is now three measured assertions (points == 0,is_bounded,predicted_winner == None) instead of prose.Still open — one, pre-existing, with a named owner. Site D's OWN-period branch is asserted by no row in the tree. Measured rather than argued: forcing that dispatch to the drain branch unconditionally leaves the whole integration suite green, because both branches end at the same
Priorityhandback once the fallback rolls back tocommitted. This PR neither creates nor closes it. It is stated in the row's own doc comment and is owned by this lane in its deferral-burndown queue.CI Failures
None.