Status: done
What to build: four findings that survived ticket 54's review rounds (recorded
in 54-vp-36038-esca.md) plus one found afterwards by human review. They share a
root cause — access state keyed by *card code* where a per-access *instance* is
needed — so they land as one ticket. Fix order below is the recommended order:
the Urtica fix changes what "correct" means for the MCTS fix.
Printed text (data/cache/all_cards.json, 30045): "When the Runner accesses
this asset while it is installed, do 2 net damage plus 1 net damage for each
hosted advancement counter." The comment at rust/netrunner-core/src/cards/sg.rs:264
truncates the text and drops the installed clause, and urtica_access deals its
damage unconditionally. Before ticket 54 that was accidentally correct (ON_ACCESS
only fired from remotes); ticket 54's zone-widened dispatcher — correct for Esca,
whose text has no installed clause — made Urtica deal 2 net on HQ/R&D/Archives
accesses, which is rules-illegal and can flatline the Runner illegally.
Ticket 54 *enshrined* the bug rather than catching it. All of these need reverting/correcting, not just the hook:
urtica_access (sg.rs:1360): early-return when accessed.id.is_none() —
AccessedCard.id is None exactly when the card is not installed, so the
plumbing already exists.
sg.rs:264: restore the full printed text including the clause.card_30045_access_deals_2_net_plus_1_per_advancement:flip it to assert no damage on a central access.
PORT-DELTA.md: the entry recording central Urtica damage as a post-port fixis wrong; correct it.
tests/ai_invariants.rs: the seed 6 → 11 swap was motivated by the illegalturn-2 flatline. With the fix, check whether seed 6 is restorable; either way the checkpoint seeds must describe legal games.
Pattern note for future cards: BANGUN (35068, unimplemented) has the same
"faceup installed agenda" qualifier — whoever implements it must check
accessed.id. Zone/installed qualifiers in on-access text are load-bearing;
audit of the other two on-access cards (Byte! 35050, Esca 36038) found both
correct against their printed text.
engine::mark_public_this_run pushes the card *code* onto RunState.revealed,
and server/serialize.py::accessed_view un-hides every accessed/pending card
whose code is in that set. Two same-code copies in one multi-access run
(Jailbreak et al. make this reachable) leak: trash copy A (legitimately public)
and the Corp's run panel names copy B — a declined, private access — including
retroactively, since accessed is re-serialized every snapshot. The Corp
illegitimately learns the top of R&D (and its own next draw).
Decided design: per-run access ordinal. RunState gets a counter bumped
when an access is dequeued; the pending access and each entry's position in
run.accessed carry their ordinal (the accessed list is already in access
order, so the ordinal is the index); revealed becomes a list of ordinals; the
serializer's membership test goes by ordinal, not code. Snapshot schema for
run.revealed changes accordingly — tests/test_reveal.py and the client run
panel need the matching update. Cards going public by leaving for a public zone
(stolen, trashed face up) mark their own ordinal.
ai/mcts.rs:475-480 handles a central queued card by finding any rezzed remote
with the same code and using *its* advancement counters. With fix #1 the correct
model is: a queued access with no InstalledId contributes zero
lethal_on_access damage (the installed-only clause never fires). Remove the
code-match fallback; consult lethal_on_access only when the queued access
carries an id.
server/serialize.py (last_run_accessed handling, ~line 388) unconditionally
suppresses last_run_accessed for the Corp, including cards the Corp was
explicitly shown (Esca's forced reveal, stolen/trashed cards). Gate it on the
same per-ordinal revealed state as #2: when the run ends, persist which entries
were public; the Corp's view keeps revealed entries (hidden placeholders for the
rest, consistent with the in-run panel) so the client's post-run card lookup and
tooltips resolve cards the Corp legitimately saw.
Verified 2026-08-03 (found by the external-review bake-off, gpt-5.6-sol).
engine::remove_accessed_card resolves central removals by *first code match*:
engine.rs:2711-2714): R&D is ordered, so this both deletes the
wrong physical copy and corrupts deck order. Multi-access R&D of
[Esca A, Hedge Fund, Esca B]: Runner declines A, then trashes B — the
engine removes A, leaving [Hedge Fund, Esca B] where the rules say
[Esca A, Hedge Fund]. The Corp draws the wrong card next turn.
engine.rs:2716-2732): the branch removes a code match from
the faceup pile and the facedown pile — two independent ifs, so one
trash/steal can delete two physical cards when both piles hold the code
(build_access_queue queues both piles, engine.rs:1523-1539).
Same root cause as #2: identity-by-code where identity-by-instance is needed.
The per-run access ordinal from #2 is the natural key here too — carry enough
provenance on QueuedAccess (source pile / position, adjusted for earlier
removals in the same run) that removal targets the accessed instance. HQ can
keep the code match (hand copies are interchangeable).
Hardening requirement while in here (bake-off, gpt-5.6-luna, verified
2026-08-03): every consumer of remove_accessed_card — steal
(engine.rs:2546), trash_card (:2588), carnivore_trash (:2663) —
pushes its *clone* of the card onward unconditionally, so a removal that
misses silently duplicates the card. No current effect can trigger this (an
accessed card never leaves its zone mid-access today), but the rework must
make a miss loud (debug assertion or log line) rather than keep it silent,
so the latent duplication cannot ship quietly with a future mid-access
effect.
Verified 2026-08-03 (bake-off, glm-5.2). Both arms of the AccessCard check
in heuristic_filter (ai/mcts.rs:504-506) require ic.rezzed, so an
unrezzed advanced Urtica — the normal way to play it; AMBUSH_CODES even
keeps the AI from rezzing it on approach — is never flagged, and the Runner
AI can access into a 2+adv flatline. The Run-arm directly below
(mcts.rs:529) already handles unrezzed content honestly: a generic
2 + advancement_tokens guess without reading the hidden card code.
Fix: mirror that generic guess for an unrezzed id-matched copy in the
AccessCard arm (advancement counters are public information; the code is
not — do not peek at it, and with #3's fix, contribute nothing for central
accesses).
Related but out of scope (bake-off, gpt-5.6-luna, verified 2026-08-03):
the Run-arm's 2 + advancement_tokens guess treats *every* unrezzed
advanced card as a potential ambush — including advanced agendas, so at low
grip the AI declines to contest scoring remotes. That is the
informationally-honest read (the hidden code must not be peeked at) and is a
deliberate-looking caution/strength tradeoff, not a rules bug. Leave the
behavior alone in this ticket; add a code comment naming the tradeoff so it
reads as chosen, and revisit only in dedicated AI-strength work (e.g.
weighting by how many ambushes the Corp could even have).
server/serialize.py::_redact_log only
anonymizes the Runner accesses: … (from rd). line. An on-access hook that
logs its own card name during an R&D access therefore leaks the hidden card
to the Corp — pre-fix Urtica's damage line does exactly this (both bake-off
reviewers found it independently). Fix #1 removes the only current instance
(Esca and Byte! reveal first, so their lines are legitimately public), but
the fix for #1 should keep this in mind and docs/adding-a-card.md should
name the rule: a non-reveal card's on-access log line must not name the
card when accessed.id.is_none() and the zone is R&D.
(card_30045_two_copies_in_one_remote_keep_their_own_advancement and the
engine/MCTS twins) construct a remote holding two assets, which is not a
legal game state; keep them as defensive coverage but add a legal-state
duplicate test (same-code central cards). tests/test_reveal.py stops
mid-access and its fixture deck is wholly illegal (34 copies, no agendas) —
the #2/#4 work should extend it to complete the run and cover a
multi-access where exactly one of two same-code copies goes public.
Ping (30055) is ice, but its registration comment (sg.rs:316) says "asset"
and card_30055_rez_tags_the_runner_only_during_a_run builds it as
CardType::Asset. Fix the comment and the fixture's card type.
Watch out for: revealed lives on RunState and dies with the run — tests
for #2 need two same-code accesses in a *single* run (multi-access). The
Corp-side assertions belong at the serializer level like tests/test_reveal.py,
which is also the test to extend rather than replace.
---
Landed 2026-08-04. All six items, in the prescribed order, plus the doc
rules the pattern notes asked for. The cleanup rider was already done by
ticket 61 (sg.rs said "ice" and the fixture was CardType::Ice); the comment
still truncated the printed text, so it now quotes "against this server" too.
One idea underneath all six: an access is a thing, a card is not. Every finding here was the same substitution — card code where a per-access instance was meant — so the fixes share two new pieces of state and nothing else.
AccessOrigin (in state.rs) is *where this copy came from*, at the
resolution needed to find it again: Remote(InstalledId), Rd(usize),
ArchivesFaceup(usize), ArchivesFacedown(usize), and Hq — HQ alone keeps
the code match, because the Corp's hand is unordered and its copies really are
interchangeable. Every QueuedAccess carries one; remove_accessed_card no
longer takes a ServerId at all, since the origin says where to look.
QueuedAccess::ordinal is *which access of this run this is* — assigned in
access_card when the entry is dequeued, and equal to its index in
run.accessed, which is why there is no second counter to drift out of step
(RunState::next_access_ordinal). RunState::revealed and the new
AccessedCard::ordinal are keyed on it.
1. Urtica Cipher's installed clause (P1). urtica_access returns early
when !accessed.is_installed(); AccessedCard::is_installed() is the named
form of id.is_none(), so the next card with that qualifier (BANGUN 35068)
has something to reach for. The registration comment quotes the printed text
in full. The HQ leg of card_30045_access_deals_2_net_plus_1_per_advancement
now sweeps HQ, R&D *and* Archives and asserts no damage — and that nothing
logged the card's name, which is the second half of the bug: _redact_log
anonymizes only the access line, so an on-access line naming the card would
have handed the Corp the top of R&D. PORT-DELTA.md records the ticket-54
entry as wrong and says why. Seed 6 is restorable and restored in
ai_invariants.rs (543 steps; the turn-2 "flatline" was the illegal damage).
2/4. Privacy by ordinal, in the run and after it (P1/P2). revealed is
now Vec<usize>, mark_public_this_run takes an ordinal, and
reveal_accessed_card takes the whole AccessedCard. The snapshot gains
run.pending_ordinal, and end_run saves last_run_revealed /
last_run_target so the finished run's visibility outlives it: the Corp's
last_run_accessed keeps the entries it was shown and gets placeholders for
the rest, instead of the blanket suppression that lost Esca's forced reveal
and every stolen or trashed card. The client needed no change — it consumes
the serializer's card views and already skips code: "hidden".
3/6. The MCTS access filter (P2). The AccessCard arm consults
lethal_on_access only for a queued access that carries an id; the code-match
fallback for centrals is gone, because with #1 a central access deals nothing.
For an id-matched *unrezzed* copy it makes the same informationally-honest
guess the Run arm makes — 2 + advancement_tokens, without reading the
hidden code — which is what the filter was missing: AMBUSH_CODES keeps the
Corp AI from rezzing an ambush on approach, so the rezzed-only check never
fired on the way the card is actually played. The Run arm's identical guess
now carries a comment naming the caution/strength tradeoff as chosen.
5. Removing the copy that was accessed (P1). R&D and both Archives piles
remove by recorded position, verified against the code still sitting there;
Archives' two ifs are one match arm each, so a steal can no longer delete a
card from both piles. Positions in the piles the run is still walking are
fixed up after each removal (shift_access_origins). remove_accessed_card
returns whether it found the copy and fires a debug_assert! plus a log line
if it did not — the callers still push their clone onward, so a silent miss
would duplicate a card, and the ticket's hardening requirement is that a
future mid-access effect cannot make that happen quietly. Eight full
MCTS self-play games (debug, both engines) trip nothing.
Determinisation had to learn about the queue. shuffle_corp_hidden
reshuffles HQ/R&D/face-down Archives wholesale, which would leave the queue's
recorded positions pointing at whatever landed there. The cards an in-flight
access queue names are now lifted out of the pool before the shuffle and put
back in their slots after (lift_access_queue_cards /
restore_access_queue_cards). No information is added by this: the queue is
cloned into the simulated state either way, so the only thing the old
behaviour bought was a state that disagreed with itself.
Tests. Rust 256 (was 251): the engine's three new access tests drive real
runs — Jailbreak's extra R&D access for the multi-access ones, a real Archives
breach for the two-pile one — and determinise gains a pinning test that also
proves the *unqueued* slots still move. tests/test_reveal.py grew from 4
tests to 10 and now plays whole runs through the PyO3 bindings: a Jailbroken
double R&D breach of two Urtica Ciphers where the Runner trashes one and
declines the other pins the Corp-side view during *and* after the run, and the
same game pins #1 end to end (no damage, no card name in the Corp's log). Its
Esca twin pins that a forced reveal survives the end of the run. The
single-card corp decks stay — they are how a test gets a deterministic top of
R&D — but the file now says so.
Two deliberate non-changes. heuristic_filter's Run arm still treats
every unrezzed advanced card as a possible ambush (the ticket scopes that out;
it now carries a comment saying so), and the HQ removal still goes by code.
Rust: 256 passed. Python: 249 passed.
---
Review panel, 2026-08-04. Nine findings on the ticket-62 diff; all nine worked, one of them by argument rather than by code.
The rule the ticket was missing: HQ is a private access too (P1). Findings 1 and 2 were the same question asked from both ends — what does each side know about an access? — and they answer it in opposite directions, so they had to be settled together.
*The Corp's side.* The ticket built the ordinal machinery and then wired it to
R&D alone, on the old comment's claim that "HQ accesses are random, so the corp
learns which card it was". That has it backwards: the access is random *because*
the Corp shuffles its hand for it, and the Corp knowing HQ as a set is exactly
why naming the pick tells it something new — what the Runner now knows.
Which card of the hand the Runner has seen and walked away from (and which it
has therefore *not* seen) is the read: whether the ambush the Corp is holding is
still a bluff, whether its agenda is still a secret. The Corp's view named the
accessed card during the run and kept naming it in last_run_accessed
afterwards. So `serialize.PRIVATE_ACCESS_SERVERS =
("rd", "hq") now drives _redact_log, hide_access and hide_last_run`
together, with the reasoning for *both* centrals — and for why Archives (breached
face up) and remotes (the Corp's own installed cards) are not on the list — in
one place instead of three. Three end-to-end tests in tests/test_reveal.py
play a real HQ breach through the bindings: hidden during the run, hidden after
it, and visible again on the copy the Runner trashed.
*The Runner's side.* lift_access_queue_cards pinned the whole queue, which
made every rollout omniscient about accesses that had not happened yet — a
Jailbroken double breach of R&D would search knowing the true order of both
cards. The rule now matches the Runner's knowledge exactly: **the pending
access keeps its identity, everything still queued follows the shuffle.** After
the reshuffle each queued entry is re-read from the slot its origin names
(follow_queued_accesses), which keeps the simulated state self-consistent —
the trash after an access still removes the card the access resolved — without
pinning anything the Runner has not seen. Queued HQ entries take *distinct*
slots of the reshuffled hand, because the sample that built the queue drew
without replacement.
That same rule fixed finding 3, which the old comment had denied: unrezzed
remote content *is* in the shuffled pool (shuffle_corp_hidden_pool builds its
content pool from exactly those slots), so mid-breach of a face-down ambush the
queue kept the original card while the slot was reassigned — and
remove_accessed_card would then delete the reassigned card and push the
original to face-up Archives, losing one and duplicating the other, with
debug_assert! never firing. A pending remote access is now pinned *in place*
(the slot is held out of the pool and counts as the server's known asset/agenda),
and a queued one follows its slot like any other. Sixteen debug self-play games,
both engines, trip nothing.
The unrezzed-access estimate: base damage in, agenda caution kept (P2).
Findings 4 and 5 pull against each other, and only one of them can be fully
satisfied without reading the hidden card code. Finding 4 is a rules fact and is
fixed: Urtica Cipher deals "2 net damage plus 1 per hosted advancement counter",
so the 2 is not conditional on the counters and an unrezzed *unadvanced* remote
card is worth 2 damage, not 0. Both arms of heuristic_filter now call one
unrezzed_access_damage = 2 + advancement_tokens, and the new test pins the
lethal-grip boundary the old fixture's three-card grip never reached.
Finding 5 is recorded as the deliberate tradeoff it is, per its own "either
fix it or record it" — with the reasoning the ticket previously only gestured
at. The estimate over-reads an advanced agenda as an ambush, and the honest
alternatives all require either peeking at the code (which would have the AI
play against a board the Runner cannot see) or weighting by how many ambushes
the Corp could hold, which is real AI-strength work and not this ticket. What
makes over-estimating the right side to err on: it only fires where the guess
would actually kill (one card in grip against a face-down remote, four against a
3-advanced one — spots where a human is wary too), death is not recoverable and
a missed steal is, and both arms now share the estimate so the Runner never runs
a remote it will then refuse to access. That last point is why the Run arm
changed after all, contrary to the ticket's original scoping: leaving it at
advancement_tokens > 0 while the access arm read the base 2 would have had the
AI spend a click walking into a remote it had already decided not to look at.
"schema": 2 (P2). The snapshot's shape changed and its version did not, so
a pre-62 trace failed against the current engine as an ordinary state mismatch —
the most expensive kind of false alarm, since an equivalent state simply reads
"revealed": [0] where the trace says ["36038"]. snapshot::SNAPSHOT_SCHEMA
is now a named constant with the shape history in its docs, and **replay checks
it before diffing** rather than migrating: a mismatched trace stops with
"re-record the trace" instead of being reported as a divergence. (Chosen over
normalizing the pre-62 shape: there is no recorder in-tree any more, so a
migration would be untestable code carried for hypothetical traces.)
is_installed() is documented for what it decides (P2). BANGUN 35068 is not
the pattern the doc claimed: it is an *identity* whose first line, "You may
install agendas faceup", creates a state this engine cannot represent at all —
an installed agenda is never rezzed, so there is no faceup flag to read — and
its trigger keys on the identity, not on the accessed card's code, which is all
ON_ACCESS can look up. is_installed() is a necessary condition for it and
not a sufficient one; hooks.rs and docs/adding-a-card.md now say so, and the
doc's redaction rule says "R&D *or HQ*" with the one legitimate exception (a
trigger that asks the Corp to decide, like Byte!'s "you may pay 4", cannot be
offered without naming itself).
Two tests that did not test their names (P3).
test_the_runner_sees_both_copies_throughout only looked at post-run data, so
_drive_multi_access now returns both sides' views at each access and the test
pins the Runner's live run panel at both. And the seed-6 claim was a comment
over a CHECKPOINTS list that stops at step 250, while the illegal flatline it
describes happened on turn 2 — the_checkpoint_seed_plays_a_whole_legal_game
plays the seed out instead (543 steps, turn 19, Corp win; the assertions are
loose bounds, because the claim being defended is "a whole game, not a two-turn
one").
Rust: 263 passed. Python: 252 passed.