← all tickets

62 — Access identity: Urtica zone clause + revealed-by-instance

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.

1. P1 — Urtica Cipher deals central-access damage its text forbids

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:

AccessedCard.id is None exactly when the card is not installed, so the plumbing already exists.

flip it to assert no damage on a central access.

is wrong; correct it.

turn-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.

2. P1 — R&D privacy leaks between identical cards (revealed keyed by code)

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.

3. P2 — central Urtica accesses incorrectly filtered as lethal by MCTS

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.

4. P2 — revealed cards disappear from the Corp's post-run card data

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.

5. P1 — trashing/stealing an accessed central card removes the wrong copy

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*:

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.

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.

6. P2 — MCTS lethality check skips unrezzed copies, inconsistently

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).

Pattern notes (no code change required by themselves)

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.

Cleanup rider

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.