← all tickets

65 — Rez-gate follow-ups: free-rez window robustness, ruleset propagation tests, guard-test gaps

Status: done

Where this came from: ticket 61's review panel, round 3 — the nine findings (P2 items 5–10, P3 items 11–13 in that ticket's Comments) that survived the review cap but were *not* P1 enough to join ticket 64. All were verified against the source by the orchestrator; none is a regression from 61. Filed here so they do not rot in a closed ticket's comments.

Tickets 61 and 64 are the context: hooks::can_rez gates every rez path, GameState.vanilla carries the ruleset over PyO3, and the free-rez window (Send a Message) validates submitted actions against its frozen candidate list.

P2 — behavior and robustness

1. free_rez_options advertises a rez that apply rejects. rust/netrunner-core/src/legal.rs:594-605 re-checks hooks::can_rez but not !ic.rezzed, while engine::free_rez errors with "That ICE is already rezzed". If anything rezzes a candidate while the window is open, legal_actions offers an unappliable action — the advertised-action invariant breaks and clients/AI can loop on it. mycoweb_can_rez does check unrezzed; make the free-rez path match. (luna)

2. A stale free-rez candidate on a removed remote panics. rust/netrunner-core/src/engine.rs:2814-2834 validates candidates.contains(...) and only then calls state.server_ice(server_key).len() — and state.rs:952-957 indexes self.corp.remotes[i] directly, so a frozen candidate naming a remote that no longer exists panics instead of returning an illegal-action error. The validation was added precisely to make stale/replayed actions safe; finish the job. (luna)

P2 — test coverage the gate is missing

3. The PyO3 vanilla handoff has no behavioral test. tests/test_unimplemented_cards.py:84-93 only asserts a game built with a vanilla list can deal an opening hand, and tests/test_engine_construction.py:118-130 monkeypatches the Python Game symbol and never crosses PyO3. Emptying vanilla_codes inside rust/netrunner-py/src/game.rs:122-128 leaves both green while production refuses to rez a deliberately vanilla ice. Needs a real hookless fixture ice that rezzes *with* the list and is refused *without* it. (terra, luna)

4. ai_invariants.rs evaluates a different ruleset than production. rust/netrunner-core/tests/ai_invariants.rs builds states with vanilla: Default::default() while the server passes manifest.vanilla_codes(). The day a vanilla ice enters a starter deck, MCTS legality/determinisation/rollout invariants are checked against a ruleset no real game uses. True today only because no starter deck has one. (terra, luna)

5. Ping is tested through hooks::on_rez, never through a rez path. rust/netrunner-core/src/cards/sg.rs:3799-3822 calls the hook directly, so nothing pins that rez_ice/rez_card/free_rez set rezzed before firing it or preserve the active run — exactly the seam ticket 64's finding 4 falls through. (Ticket 64 fixes the lifecycle; this item is the missing end-to-end test coverage, and is best done alongside it.) (luna)

6. Nothing exercises the retired-ETR behavior through a real encounter. hooks.rs:544-568 / engine.rs:1938-2042: the lookup test uses an arbitrary unregistered code, so a registered ice that loses its .ice_subs(...) would encounter as a silently sub-less ice with no card-specific test failing. (luna)

P3 — minor

7. The constructor guard misses aliased constructors. tests/test_engine_construction.py:37, 79-90, 113-115 — the pattern matches only a callee literally named Game; import Game as RustGame or factory = netrunner_engine.Game evades it while test_the_guard_would_notice_a_new_caller claims to cover "every spelling". Anchor the pattern to the name actually bound by ENGINE_IMPORT. (glm, terra, luna)

8. Replay accepts malformed header.vanilla silently. rust/netrunner-replay/src/main.rs:95-112filter_map drops non-string entries, so {"vanilla": [30055]} or ["30055", null] yields a partial or empty set with no warning, while a *missing* field warns loudly. vanilla_from_header has no test at all. (terra, luna, glm)

9. Decision-presence booleans are not gated to the decider. server/serialize.py:427-432pending_planogram, pending_reality_plus, pending_precision_design, pending_retribution and pending_wildcat are _has_decision(...) for both sides. Payloads are gated (ticket 61 round 2); the *existence* of an opponent's optional decision is still public, which can reveal that a private trigger fired. Decide whether that is acceptable and either gate it or pin the choice with a test. (luna)

Blocked by: none. Item 5 overlaps ticket 64's finding 4 (Send a Message post-run rez) — whoever works both should do 64 first or together.

Comments

2026-08-04 — all nine items closed

Suite green at close: cargo 284 (was 275), pytest 261 (was 257). Each fix was confirmed by temporary revert — the new tests fail without it.

1. free_rez_options re-checks !ic.rezzed (legal.rs). The listing now asks everything engine::free_rez will ask, so an advertised free rez is an appliable one. Pinned by card_30069_stops_offering_a_candidate_that_got_rezzed_meanwhile, which rezzes the candidate through the ordinary paid-ability rez while the window is open and asserts the listing and apply agree. 2. GameState::try_server_ice — the non-panicking server_ice, used by both the free-rez listing and engine::free_rez. A frozen candidate naming a remote that is gone is now an illegal action. (card_30069_free_rez_on_a_vanished_remote_is_refused_not_a_panic; without it the test panics in state.rs, in legal_actions, before apply is reached.) 3. The PyO3 vanilla handoff has a behavioral test. test_the_vanilla_list_is_what_decides_whether_a_hookless_ice_rezzes builds the *same* unimplemented ice on the same board twice through the raw constructor — once with vanilla=[], once with vanilla=[that code] — and asserts it is neither offered nor accepted under the first and both under the second. Confirmed by rebuilding netrunner-py with vanilla_set returning Default::default(): only this test goes red. 4. ai_invariants.rs proves its ruleset is production's rather than assuming it. Rust cannot read the manifests (ADR-0001), so instead starter_decks asserts every piece of ice in either starter deck registers hooks — which makes the empty vanilla set and the real list agree on every rez the file can reach. Named as the_starter_decks_are_played_under_productions_rez_rule; the day a vanilla ice joins a starter deck it fails there, with the reason. 5. Ping through the rez paths. Ticket 64 already drove free_rez end to end (card_30069_stolen_on_the_last_access_...), so this item was *partially* covered on arrival — that work was not redone. What was still hook-only is the other two live paths, now covered by card_30055_rezzed_in_the_approach_window_tags_and_then_ends_the_run (engine::rez_ice) and card_30055_rezzed_by_the_paid_ability_mid_run_still_tags (engine::rez_card). 6. The retired ETR, through a real encounter. The same approach-window test continues past the rez with Action::PassIce and asserts Ping's *registered* "End the run." resolves and ends the run — and that the "no subroutines to resolve" line is absent. Dropping .ice_subs(&["End the run."]) from reg.card("30055") turns it red, which is the card-specific failure the unregistered-code lookup test could not give. 7. The constructor guard is anchored to what the import bound. _constructor_names resolves the module aliases and the `from ... import Game [as X] names per file, follows one assignment hop (factory = netrunner_engine.Game`), and searches for calls on those. The old literal Game( pattern stays as a floor, so the guard only ever widened. test_the_alias_resolver_reads_the_import_and_not_the_call pins the resolver on its own. 8. vanilla_from_header splits into parse_vanilla_header (codes + optional warning) so the warnings are testable, and now distinguishes missing / not-a-list / non-string-entries, naming what it dropped. Four tests, where there were none. 9. Decided: gate them. _has_decision takes the same decider rule as _dec_data. Predictive Planogram is the case that settles it — the window only opens on the untagged branch, so its existence reports which branch the Corp's operation took. Nothing is lost: legal_actions is already decider-gated, so a client told the window is open has nothing to render. Pinned by test_a_payload_less_window_is_still_the_deciders_alone and its reveal_hidden counterpart in tests/test_decision_privacy.py.