← all tickets

61 — Engine: make "unimplemented ICE is never rezzed" true everywhere

Status: done

What to build: the engine-side half of the policy docs/adding-a-card.md already states: *"unimplemented abilities never activate … unimplemented ICE can be installed (bluff value) but never rezzed. Nothing is fabricated — the old default-'End the run' fallback for unknown ICE is retired."*

None of that is enforced in rust/netrunner-core today (found while implementing Mycoweb, ticket 59):

says they arrive "alongside the deck at game creation via PyO3", but rust/netrunner-py/src/game.rs::Game::new takes identities, deck code lists and a seed — nothing else. The manifests (game/cards_impl/*.json) are read only by the server's card endpoints and by the honesty tests.

["End the run."] for any code with no ICE_SUBS entry, so an unimplemented piece of ice rezzes normally and fabricates an ETR — exactly what the policy says was retired. (Ping, 30055, is the reason this cannot simply become "no subs": it prints exactly "End the run." and deliberately rides the default.)

rez), engine::rez_ice (the approach window) and engine::free_rez (Send a Message) all charge the cost and flip rezzed with no implementation check; legal.rs offers all three the same way. The client's "disabled rez button with tooltip" that the policy describes has nothing behind it.

What ticket 59 did in the meantime: Mycoweb's discounted rez (cards::elev::mycoweb_rez_candidates) filters its targets through hooks::card_is_implemented, so *that* path honors the rule. That helper — "does this code register any hook" — is deliberately the only test today, and its doc comment says what it cannot see: a card that is *vanilla* on purpose (printed stats, zero hooks) is indistinguishable from one nobody has written yet. Today that is harmless (the vanilla audit found exactly two vanilla cards in the pool, both identities), but it is not a rule to spread to the click-rez path unexamined.

Scope (design decided 2026-08-03, grilling session):

1. **Carry only the vanilla set into the engine at game creation (PyO3); derive implemented from the hook registry.** The honesty tests prove the equivalence exactly: check (a) makes registered ⊆ implemented and check (b) makes implemented ⊆ registered ∪ vanilla, so the gate hooks::card_is_implemented(code) || state.vanilla.contains(code) *is* the manifest bit, with no copied data that can drift (and no ADR-0001 violation — Rust still never parses manifests). Game::new gains the vanilla code list; the manifests stay the human-facing truth on the Python side.

Consequence: the gate is enforceable in pure Rust, so rust/ test fixtures that rez cards under unregistered codes get blocked. Resolution: test helpers/fixtures mark their fixture codes vanilla on the test GameState — a fixture card with printed stats and no hooks *is* a vanilla card. Expect mechanical churn in Rust tests.

2. One gate, every path. Route engine::rez_card, engine::rez_ice, engine::free_rez and legal.rs through the single predicate from (1) — legal_actions never offers a rez the rule forbids. Retire ticket 59's ad-hoc check in cards::elev::mycoweb_rez_candidates into the same gate. AI *install* behavior stays untouched: unimplemented ice keeps its install-for-bluff value per the policy; teaching the AI not to install never-rezzable ice is a possible follow-up, not this ticket. Expect tests/ai_invariants.rs checkpoint-seed churn from the legality change — known cost, not a regression (likely zero if no test deck contains unimplemented ice; verify).

3. Retire the default-"End the run" subs. Ping (30055) already registers on_rez (implemented, tested, non-vanilla) — give it an explicit .ice_subs(&["End the run."]), a pure refactor. Then get_ice_subs with no entry returns empty, loudly (log line / debug marker "no subroutines registered for <code>"): nothing is fabricated, and a forgotten ice_subs registration on a future ice is visible instead of silently doing nothing. No panic — a legitimately sub-less or vanilla ice must not crash a game.

4. Client: badge + tooltip, not a literal disabled button. The client (static/game.js) renders rez affordances from legal_actions, so once the gate filters the rez there is no button to disable. Instead: card_view stamps unimplemented: true from the manifests the server already reads (server/routers/cards.py); the client shows a marker + tooltip ("Not implemented — can be installed but never rezzed") on the Corp's own unrezzed cards. Corp-only: the flag must only be stamped where the corp already sees the card — the Runner must not learn a facedown card is unimplemented (that is itself information). Pin that with a serializer test. Amend the "disabled rez button" wording in docs/adding-a-card.md to match.

Watch out for: the honesty tests (tests/test_card_manifest_honesty.py) and tests/test_registry_inventory.py describe the same territory from the Python side; the design in (1) exists precisely so no second source of truth appears — do not add one back (e.g. by also passing the implemented bits).

---

Landed 2026-08-03. All four scope items, as designed.

One bit crosses PyO3, not two. GameState.vanilla (an Arc<HashSet<String>>, because every MCTS node clones the state and the set never changes) carries the manifests' vanilla codes in via Game(..., vanilla_codes) / game/manifest.py::vanilla_codes. implemented is *derived*: hooks::card_is_implemented(state, code) is "registers a hook or is vanilla", which the honesty tests prove equal to the flag. The registration-only half kept its own name, hooks::code_registers_hooks.

The gate is hooks::can_rez(state, card), and it is ICE-only. rez_card, rez_ice, free_rez and mycoweb_can_rez all call it, and legal.rs calls it in three places (the approach window, free_rez_options, Mycoweb's options) so the forbidden rez is never offered — plus send_a_message no longer lists unimplemented ice as a candidate at all, so the window does not even open on a board of nothing but unimplemented ice. An unimplemented *asset* still rezzes and sits inert: that is what the policy says, and rezzing one fabricates nothing.

Test churn was smaller than feared. No ai_invariants checkpoint moved (the starter decks contain no unimplemented ice, as the ticket guessed), and only the new GameState.mark_vanilla test helper was needed — the existing fixtures that rez ice all use real, registered codes. Two existing assertions changed to the new policy rather than being deleted: get_ice_subs_defaults_to_single_etr_for_unregistered_ice became ..._is_empty_for_unregistered_ice_rather_than_a_fabricated_etr, and the registry drift guard went 172 -> 173 registrations for Ping's new ice_subs.

The badge is bound to the card's side, not to visibility. card_view stamps unimplemented only when card.side == human_side — not "when this view happens to be visible", and reveal_hidden does not widen it. Whether a face-down ice is implemented is exactly what would tell the Runner the bluff is a bluff. tests/test_unimplemented_cards.py pins that the word does not appear anywhere in the Runner's serialized view.

Rust: 245 passed. Python: 224 passed.

---

Review-panel follow-up, 2026-08-03. A three-model panel read the landed commit; seven findings, all addressed.

The bluff was not actually protected (P1). The badge was hidden from the Runner, but the *card* was not: run.current_ice was serialized with no hidden argument, so during the approach window the Runner's JSON carried the unrezzed ice's title, code, keywords and strength — the client rendered "Unrezzed ICE" over the top of it, which is not a privacy mechanism. Send a Message's pending_free_rez leaked the same way, naming every unrezzed-ice candidate the moment an agenda scored. Both now take the same hidden=hide_from_runner rule as every other installed view. The test that gave false confidence ("unimplemented" not in json.dumps(view)) now asserts the ice's title and code are absent too — the status flag is only worth hiding while the identity is, since a Runner holding the title can look the implementation up out of band. That scan had to be scoped to the Corp's zones, because the Runner's own grip and rig carry the badge legitimately.

A card in no manifest at all is unimplemented (P2). card_view tested membership of unimplemented_codes(), which only holds manifest entries flagged implemented: false — so an ice in all_cards.json but in no pack manifest was refused a rez by the engine (no hooks, not vanilla) with no explanation for the Corp. It now calls manifest.is_unimplemented(), which was written for exactly this and had zero call sites.

The vanilla list reaches every caller (P2). New game/engine.py::new_game is the one way to build a Game on the Python side, and tests/test_engine_construction.py guards that the raw constructor has no other caller (two documented exceptions). The two Rust binaries cannot reach a manifest (ADR-0001): netrunner-replay now reads header["vanilla"] out of the trace — the recorder is Python and the ruleset belongs in the trace next to the decks and the rng — and selfplay takes --vanilla=<codes>. Each warns loudly on stderr when it has neither, naming the divergence it will cause, instead of defaulting silently.

The marker tells the truth per card type (P3). One ice-shaped sentence was stamped on events, programs and assets alike. UNIMPL_TITLES in game.js now words it per type — only ice is barred from rezzing; an asset rezzes and sits inert; an agenda still scores. The badge also reaches the paths it was missing (card piles, the rig), the hand's image-error handler no longer wipes the label that carries it, and .hand-card.unimplemented finally has CSS.

Two more rez paths pinned, and the warning moved to the log (P3). rez_card (apply-only for ice: legal_actions never offers it, so the gate has to live in apply) and Mycoweb's discounted rez now have direct tests that a refactor dropping hooks::can_rez would fail. get_ice_subs is a pure lookup again — it is called from legal_actions and from AI rollouts, so it had no business writing to stderr; engine::pass_ice writes "<ice> has no subroutines to resolve." to the game log, where the table and the tests can see it.

Rust: 247 passed. Python: 235 passed.

---

Second review panel, 2026-08-03. Eight findings on the cumulative diff. Seven were real; the eighth was half right and is written up as such.

**The candidate *count* was the leak, not the candidate (P1).** The first round hid the free-rez candidates' cards from the Runner and left server_key, slot_index and the list length in place — and since send_a_message filters its candidates through hooks::can_rez, that list is a proper subset of the unrezzed ice whenever the board mixes the two. One entry against two visible face-down ice on HQ names the bluff without ever naming a card. The fix is a gate, not more redaction: state_to_dict._dec_data now resolves a decision's payload only for its own decider, exactly as legal_actions already was. The payload rides along with the actions, to the side that acts.

That gate is uniform, so it also closed pending_seamless (which additionally went out as a raw _serialize(card) dump, bypassing card_view and with it the side-bound unimplemented marker policy — it takes card_view now), pending_install_card, pending_hiram_card, pending_archives_recur (facedown Archives, not in the panel's list but the same window shape) and pending_zahya. pending_stack_search had its own hand-rolled version of the same check, which is now the shared one.

Hiram's peek: the split has to be per side, so it lives in _redact_log. The panel asked for the Rust log line to stop naming the card. It cannot: the Runner's log *is* the record of what Hiram saw, and one shared engine log projected per side is how every other private line in this game already works (R&D accesses, Cultivate, Empiricist, Mycoweb). So vp.rs and its test are unchanged and _redact_log rewrites the line for the Corp — which is the side that must not have it, R&D being shuffled and face down to its owner too.

Mutual Favor was not leaking the card (disagreement). The panel read pending_install_card as handing the Corp "the selected Stack card's title, code and text". The card's printed text is "Search your stack for 1 icebreaker and reveal it" — the found card is public by the ability that found it, and the engine logs the reveal to both sides. The private half is the *search list* (every icebreaker still in the stack, and how many), which was already gated and now is by the shared rule. The decision payload is gated too, on the uniform rule rather than on a secrecy claim, and test_decision_privacy.py pins the published reveal so a future redaction does not go rewriting it.

Forged free-rez actions are refused at apply time (P1). legal_actions filtered correctly, but server/routers/games.py passes submitted action JSON straight to Game.apply, so a handcrafted free_rez naming any slot was taken: including an already-rezzed one, which re-fires on_rez and hands the Runner a second Ping tag mid-run. engine::free_rez now validates against the window's own frozen candidate list *and* present eligibility (unrezzed, can_rez), the same shape as mycoweb_rez.

selfplay --vanilla= with nothing after it is a missing list (P2). It parsed as a supplied-but-empty ruleset and suppressed the warning — precisely the shape a failed --vanilla=$(python ...) substitution takes. VanillaArg now distinguishes Missing from Empty and warns as loudly for both (a self-play run that refuses to start would be worse than one that says what it is playing). netrunner-replay is left alone: an empty "vanilla": [] from a real recorder is a genuine answer, and the field's presence is the signal there.

Two tests that were not testing what they claimed (P3). The construction guard only searched five directories (missing ai/ and the top-level main.py) and only matched the literal netrunner_engine.Game(; `from netrunner_engine import Game` or an aliased module would have walked past it. It now looks for the pair — a file that imports the engine, in any of the four spellings, and calls Game(/Game.from_deal( — and the "would it notice" test spells out each form, plus the prose in game/manifest.py that must *not* trip it. And the free-rez privacy tests spliced a synthetic decision into a snapshot, so they could never have seen the count leak; they now drive a real game to a real on_score over a board holding one implemented and one unimplemented piece of ice, which is the situation the finding describes.

tests/test_decision_privacy.py is new and covers the gate from both sides for Seamless Launch, Mutual Favor and Hiram — a privacy test that never looks at the deciding side's view cannot tell hiding from breaking.

Rust: 251 passed. Python: 243 passed.

---

Comments

Review round 3/3, 2026-08-04 — cap reached, these 13 findings survive. The three-model panel (glm = z-ai/glm-5.2, terra = openai/gpt-5.6-terra, luna = openai/gpt-5.6-luna) read the cumulative diff 701d808..aa23801. Rounds 1 and 2 were closed in c0a1b42 and aa23801; round 3 found no regression *in* this ticket's changes but kept pulling on the thread the ticket started — "who is allowed to know the identity of an unrezzed piece of ice" — and found four P1-class holes in other code paths, plus the ruleset-propagation and guard-test gaps below. All 13 were verified by the orchestrator against the source; none were rejected. Ticket 61 closes as DONE-WITH-FINDINGS: its own scope is delivered and both suites are green (Rust 251, Python 243).

The four P1s are pre-existing and belong to cards outside this ticket; they are filed as ticket 64 so they do not rot here.

Update 2026-08-04: the remaining nine findings (items 5–13 below) are filed as ticket 65 (65-rez-gate-hardening-and-guard-test-gaps.md), status ready-for-agent. Nothing below is left unfiled.

P1 — wrong outcome or information leak

1. Send a Message can resolve after the run has ended. engine.rs:2568-2571 (hooks::on_steal then complete_access) and 2709-2713 (complete_access calls end_run the moment the access queue empties). Steal Send a Message as the *final* access of a run: the Corp's FreeRez decision is pushed, the run is then torn down, and the Corp resolves the free rez with state.run == None. A Ping protecting the attacked server gives no tag, though it was rezzed during the run — and any future on-rez trigger that reads the run resolves in the wrong state. The existing Send a Message tests call hooks::on_steal directly and never drive the final-access lifecycle. (terra, luna) 2. Sipa's legal actions hand the Runner the title of every installed ice. legal.rs:456-465sipa_options emits `SipaSwap { target_ice_id, target_title } for every corp ice with no rezzed filter, actions.rs:415-419` carries the title, and server/serialize.py forwards the Runner's legal_actions unchanged. The Runner reads the titles of unrezzed ice out of the action list and can look up whether one is an unimplemented bluff. This walks straight around the card_view hiding and the decider gate. (terra, luna) 3. Sipa's swap log leaks the target's identity permanently. engine.rs:4104 writes Sipa: swapped <src> with <tgt>. and _redact_log (server/serialize.py:31-121) has no Sipa rule, so the unrezzed target's title sits in the Runner's log history even if finding 2 is fixed. (terra, luna) 4. Brân/Ansel install-inward logs leak the installed ice. engine.rs:2892 writes Corp installs <title> inward from Brân. (the same handler serves Ansel). _redact_log rewrites Corp installs .+ as ICE on ... but not this format, so the Runner learns the identity of a facedown ice installed mid-run. A correct redaction has to keep the faceup-Archives case visible, since that source is already public. (terra, luna)

P2 — quality, AI, robustness

5. free_rez_options advertises a rez that apply rejects. legal.rs:594-605 re-checks hooks::can_rez but not !ic.rezzed, while engine.rs free_rez errors with "That ICE is already rezzed". If anything rezzes a candidate while the window is open, legal_actions offers an action that cannot be applied — the advertised-action invariant breaks and clients/AI can loop on it. mycoweb_can_rez does check unrezzed. (luna) 6. A stale free-rez candidate on a removed remote panics. 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 new validation was added precisely to make stale/replayed actions safe. (luna) 7. The PyO3 vanilla handoff still 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) 8. ai_invariants.rs evaluates a different ruleset than production. rust/netrunner-core/tests/ai_invariants.rs:89-99, 239-247 build 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) 9. Ping is tested through hooks::on_rez, never through a rez path. 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 — which is exactly the seam finding 1 falls through. (luna) 10. 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

11. The constructor guard misses aliased constructors. tests/test_engine_construction.py:37, 79-90, 113-115 — `CONSTRUCTOR = \bGame(?:\.from_deal)?\s*\( matches only a callee literally named Game`. from netrunner_engine import Game as RustGame; RustGame(...), or `factory = netrunner_engine.Game; factory(...)`, evades it, and test_the_guard_would_notice_a_new_caller claims to cover "every spelling that reaches the constructor". Anchor the pattern to the name actually bound by ENGINE_IMPORT. (glm, terra, luna) 12. 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. A divergence then reports as an ordinary snapshot mismatch. vanilla_from_header has no test at all, and no recorder in-tree writes the field. (terra, luna, glm) 13. 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. The payloads are gated now, but the *existence* of an opponent's optional decision is still public, which can reveal that a private trigger fired. Weaker than the payload leaks and possibly acceptable, but untested either way. (luna)