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):
docs/adding-a-card.md
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.
hooks::get_ice_subs returns
["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.)
engine::rez_card (the click/paid-ability
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.
---
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.
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-465 — sipa_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)
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)
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-112 — filter_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-432 — pending_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)