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.
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.
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.
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).
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.
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:
tao_options was rerouted onto all_corp_ice_labeled but only
Sipa had a payload test; a mutation restoring title-bearing Tāo labels stayed
green. Fixed in f715363.
DoneAccessing arriving while the deferred
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.