← all tickets

64 — Unrezzed-ice identity leaks (Sipa, Brân/Ansel) and Send a Message's post-run rez

Status: done

Where this came from: ticket 61's review panel, round 3. Ticket 61 made "unimplemented ICE is never rezzed" true and hid the *badge* and the pending decision payloads from the Runner. The panel then found four P1 holes in the same hidden-information boundary that belong to other cards' code paths, so they were filed here instead of widening 61 past its review cap. All four are pre-existing; none is a regression from 61. Verified against the source by the orchestrator; reported independently by two models each (terra, luna).

The privacy premise, from docs/adding-a-card.md and ticket 61: whether a facedown piece of ice is a bluff is itself hidden information, and the Runner must not learn an unrezzed ice's identity — not through a card view, not through legal_actions, and not through the log.

1. P1 — Sipa's legal actions publish every installed ice's title

rust/netrunner-core/src/legal.rs:456-465. sipa_options walks all_corp_ice and pushes Action::SipaSwap { target_ice_id, target_title } for every ice that is not the one Sipa just passed — with no rezzed filter. rust/netrunner-core/src/actions.rs:415-419 carries the title on the wire and server/serialize.py forwards the Runner's legal_actions verbatim, so the Runner's own action list names unrezzed Corp ice.

Scenario: Corp has an unrezzed unimplemented ice on R&D as a bluff. The Runner runs HQ, passes the outermost ice with Sipa installed, and reads target_title: "<the bluff>" out of its swap options. It can then look the card up out of band and know the bluff can never be rezzed.

The fix has to decide what Sipa's chooser legitimately shows: the Runner must be able to *pick* a target, so the action needs a stable id, but the title must be withheld (or replaced with a positional description — "outermost ice on R&D") whenever the target is unrezzed. Note the same treatment probably applies to any other option-builder that stamps *_title on a hidden card; audit legal.rs for the pattern rather than fixing Sipa alone.

2. P1 — Sipa's swap log leaks the target permanently

rust/netrunner-core/src/engine.rs:4101-4105 writes Sipa: swapped <src title> with <tgt title>. into the shared log, and _redact_log (server/serialize.py:31-121) has no Sipa rule. Even with (1) fixed, resolving the swap puts the unrezzed target's identity into the Runner's log history for the rest of the game.

Follow the pattern already used for R&D accesses, Cultivate, Empiricist, Mycoweb and (as of ticket 61) Hiram: the engine keeps writing the full-information line, _redact_log projects it per side. A swapped-*into*-play ice the Runner already saw rezzed must keep its title; the redaction has to key on rezzed-ness at log time, which means the log line needs enough structure to redact.

3. P1 — Brân/Ansel install-inward logs leak the installed ice

rust/netrunner-core/src/engine.rs:2857-2893 (and the Ansel path at 2204-2228, which shares the handler) writes Corp installs <title> inward from Brân.. _redact_log's Runner rules rewrite Corp installs .+ as ICE on <server>. and Corp installs .+ in remote <n>. but not this format, so an ice installed mid-run from HQ or facedown Archives is named to the Runner while it sits there unrezzed.

Careful: the source matters. Installing from faceup Archives is public — the Runner watched that card sit there — so a blanket redaction would hide something the Runner is entitled to know. Preserve the faceup-Archives case (either a distinct log line or a flag the redactor can read).

4. P1 — Send a Message's free rez resolves after the run has ended

rust/netrunner-core/src/engine.rs:2568-2571 runs `hooks::on_steal(state, &card) — which for 30069 pushes the Corp's FreeRez` decision (cards/sg.rs:1785-1807) — and then falls through to complete_access(state), which at 2709-2713 calls end_run(state, true) as soon as the access queue is empty. Steal Send a Message on the last access of a run and the run is torn down with the Corp's decision still pending; the Corp then rezzes with state.run == None.

Scenario: Runner runs HQ, accesses one card, steals Send a Message. Corp is offered the free rez and picks the unrezzed Ping protecting HQ. Ping's on_rez requires an active run against the server it protects (cards/sg.rs:1544-1552) — state.run is gone, so the Runner is not tagged, though the ice was rezzed by a trigger that fired *during* the run. Any future on-rez trigger reading the run has the same problem.

The run teardown has to wait for triggers opened during access to resolve — i.e. complete_access must not end_run while a decision pushed by an access trigger is pending. Check what else rides complete_access: this is a shared lifecycle seam, so a fix here needs the on-steal/on-score trigger suite driven end to end, not hook-level tests.

Test defect that hid this (ticket 61 review, finding 9): every Send a Message and Ping test calls hooks::on_steal / hooks::on_rez directly. Nothing drives a real final access through engine::apply, which is why a lifecycle bug this central is untested. Fix the tests along with the behavior.

Blocked by: none — all four are independent of each other and of tickets 62 and 63, though (1)-(3) share _redact_log/legal.rs and are best done together.

Comments

2026-08-04 — review panel (luna), 3 rounds, cap reached

Commits: 5b81ad6 (the four P1 fixes), f715363 (Tāo payload test, round-1 finding), 3224295 (stale-DoneAccessing guard, round-2 finding). Suite green at close: cargo 275, pytest 257.

Fixed during review:

Sipa had a payload test; a mutation restoring title-bearing Tāo labels stayed green. Fixed in f715363.

run held a decision open took done_accessing's Some(false) arm and called end_run directly, so the Corp resolved FreeRez with state.run == None and Ping's on-rez tag did nothing — item 4's bug by a second route, reachable without forgery (apply_action has no legality gate; server/routers/games.py:147 applies whatever the client sends). Fixed in 3224295 via reject_while_deciding, guarding done_accessing and the Action::JackOut dispatch arm.

**Surviving findings (open — review cap reached, not defects in behavior, both test-coverage gaps; verified against the source by the orchestrator):**

1. P2 — facedown-Archives Brân install is untested. rust/netrunner-core/src/engine.rs:3166, let label = hidden_ice_label(&card.title, source == "archives_fu");. The behavior is correct (out of HQ or facedown Archives the title rides inside the marker), but the added tests cover HQ and *faceup* Archives only (engine.rs:4832 uses "archives_fu"). A mutation that treated "archives_fd" as public would keep the suite green while the Runner's log gained the title of an ice it never saw. Wanted: an archives_fd case driven through engine::apply_action, asserting the engine marker and the Runner-redacted log.

2. P2 — the terminal-win exception in complete_access is untested. rust/netrunner-core/src/engine.rs:2888-2895. When the final-access steal also decides the game, complete_access deliberately does *not* defer (if !state.decisions.is_empty() && !state.game_decided()) and ends the run at once. Existing final-access tests leave the Runner at two agenda points, so this branch never runs. A regression that always defers, always tears down immediately, or leaves the free-rez decision live after game over would pass. Wanted: a Runner on five points stealing Send a Message on the final access, pinning winner, phase, run, and the decision stack.