← all tickets

69 — Resolve corp discard-phase-end decisions before the turn handover

Status: done (commit 53a8913, 2026-08-05)

Where this came from: the 2026-08-04 live-game stall (game bc3085ab, human Corp vs AI Runner). The stall itself was a serializer bug — server/serialize.py gated the human's legal_actions on active_side instead of game.decider() — fixed that day, with regression tests in tests/test_decision_privacy.py (test_a_cross_turn_decision_*). This ticket is the follow-up the debugging surfaced: the engine ordering that *manufactured* the game's only cross-turn decision in the first place.

The misordering

Sericulture Expansion (35049): "When your discard phase ends, you may remove 1 hosted agenda counter to place 2 advancement counters on 1 installed card." The discard phase is the last phase of the Corp's turn, so by the rules that window opens and resolves inside the Corp's turn, before the Runner's turn begins — and before the Runner's turn-begins triggers.

What engine::pass_turn (rust/netrunner-core/src/engine.rs, the active_side == Corp branch) actually does, in order:

1. discard down to hand size 2. hooks::trigger_discard_phase_end — pushes the Sericulture decision (decider: Corp) onto the stack 3. active_side = Runner, phase = RunnerTurnStart, Runner gets 4 clicks, log --- Runner's Turn --- 4. hooks::trigger_runner_turn_start — Smartware Distributor et al. fire 5. phase = RunnerTurn

So the Corp answers its own end-of-turn window as the first thing of the Runner's turn, *after* the Runner's turn-begins triggers have resolved. The shortcut is deliberate and documented (cards/elev.rs, the "Timing note" on sericulture_discard_phase_end), on the argument that nothing between the two moments reads or writes what the decision touches.

Why it is worth fixing anyway

The shortcut is outcome-equivalent today (verified during the debug):

placing advancement counters;

binds under neither ordering, because the engine has no post-discard scoring window.

But it costs:

that deadlocked the serializer (AI driver waits on decider(), human polls an empty legal_actions). The serializer is fixed; the engine shape that produced the trap remains.

and Smartware paying out before the Corp's end-of-turn prompt — reported as confusing by the user who hit it.

commuting with every future discard-phase-end decision. Nothing checks this; it just has to stay true.

Design (decided)

Split the handover so the decision stack drains while it is still the Corp's turn:

1. New GamePhase::CorpTurnEnd. 2. In pass_turn's corp branch: discard down → trigger_discard_phase_end → if the decision stack is non-empty, set phase = CorpTurnEnd and return, leaving active_side = Corp. If the stack is empty, complete the handover as today. 3. Extract steps 3–5 above into fn begin_runner_turn(state). After a decision resolver pops the stack (the shared point is restore_priority / the post-resolution path in apply_action), if phase == CorpTurnEnd and the stack is now empty, call begin_runner_turn. Only when empty — several scored copies (plus Jinteki: Restoring Humanity, which resolves inline and pushes nothing) can stack multiple windows, resolved LIFO as trigger_discard_phase_end already promises.

Ripples to check

SNAPSHOT_SCHEMA / tests/test_wire_golden.py expectations.

new phase (legal-action preemption already handles the options themselves). ai_invariants.rs seed claims (CHECKPOINT_SEED, the broad sweep) may shift where a self-played game contains a Sericulture window; the bounds are loose on purpose — re-validate, don't pin.

sericulture_target doc line about "answered first thing in the Runner's turn"; re-check the no_score_turn stamp comment (semantics unchanged — it still never binds until a discard-phase scoring window exists).

tests/test_decision_privacy.py stops on pending && active_side == "runner" — after this change the window lives at active_side == "corp", so the stop condition and the two tests' cross-turn framing must be updated *without losing what they pin*: the decider gate in serialize.py. Keep a decider ≠ active-side state under test — a run's corp windows (rez, Byte!) still provide one naturally.

in normal turn order; confirm the Runner-side client shows a sensible waiting state rather than an actionable board.

Acceptance

1. While a Sericulture window is open: active_side == "corp", phase == "corp_turn_end", the Runner has 0 clicks, and no runner turn-begins trigger has fired. 2. Log order: the Sericulture prompt and its resolution precede --- Runner's Turn ---. 3. Two scored copies stack two windows; both resolve (in LIFO order), then exactly one handover happens. 4. make test green, including the updated decision-privacy tests and re-validated ai_invariants seed claims.