← all tickets

45 — Complete the per-target action_key sweep; drop cosmetic heap-trash log redaction

Status: fixed

Decision (2026-08-02, triage with maintainer): The audit's section A is approved in full (including the low-value tail), section B is resolved here rather than deferred, and the keying policy is refined from ticket 46's two-halves rule into three cases:

1. Public outcome → code key, same in both trees. When the chosen card's identity is revealed by resolving the action (played operations, runner installs, cards landing in the public heap, revealed search picks), key by card_code. No observer arm needed — matches the existing play_event/install_program precedent. Index keys here would be strictly worse: different cards at one index share an edge, one card at different indices splits. 2. Hidden outcome → index key for the actor + redacted observer arm (the ticket-43/46 pattern; the four Corp HQ choices and facedown Archives stay exactly as shipped). 3. Public position → slot/id key, same in both trees (the advance_card/rez_card precedent; a Runner-root determinisation may swap which card sits in an unrezzed slot — accepted, consistent, not novel).

Key table — public outcome (code keys)

| Action | Key | Notes | |---|---|---| | PlayOperation | play_operation:{card_code} | Schema: add card_code: String with #[serde(default)], populate in legal.rs — the audit's open question, settled by case 1 | | PantographInstall | pantograph_install:{card_code} | field exists | | IlluminationInstall | illumination_install:{card_code} | field exists | | MethuselahTrash | methuselah_trash:{card_code} | field exists; log names the card and the heap is public — see redaction section | | CarnivoreTrash | carnivore_trash:{card_code} | Flips ticket 43's index key and removes ticket 46's Corp observer arm — see redaction section | | ChooseSearchCard | choose_search_card:{card_code} | Schema: add card_code (serde default), populate in legal.rs from the decision's candidate list — resolves audit section B, see below |

Key table — public position (slot/id keys)

aircheck_run:{target} (the priority row — the search currently cannot distinguish which server Aircheck runs at), above_the_law:{index}, retribution:{slot}:{index}, seamless_target:{server_index}:{slot_index}, free_rez:{server_key}:{slot_index}, use_anoetic_void:{server_index}:{slot_index}, knickknack_trash:{slot}:{index}, sipa_swap:{target_ice_id}, tao_first_ice:{ice_id}, tao_swap:{ice_id}, use_botulus:{program_index}:{sub_index}, use_resource:{slot}:{resource_index}, use_leech:{program_index}, use_carnivore:{hardware_index}, pump_breaker:{breaker_index}.

Same key both trees, no observer arms. The low-value tail (use_resourcepump_breaker) is deliberately included: same one-line fix, and it leaves the action_key fallback holding only payloadless actions — an invariant worth having, rather than a curated exception list.

Log redaction: drop the two cosmetic rules

The runner heap is serialized ungated with full card identity for both sides (serialize.py:343, via card_view) — the heap is a public zone. So the two corp-side log anonymizations for cards that land there hide nothing from an attentive observer and are dropped:

grip") — with it gone, Carnivore's outcome is public and its key follows case 1 in both trees; remove observed_action_key's (Corp, CarnivoreTrash) arm from ticket 46 (that ticket's text explicitly anticipated the observer key following any redaction-rule change; a superseding comment is appended there).

observed_action_key already gives a Corp observer the full discard key, so the log simply catches up to the keys.

The genuinely-hidden rules stay: Corp installs/draws/advances, Corp discards (facedown Archives), the HQ-trash and Sprint anonymizations, and unstolen R&D accesses.

Section B resolution (ChooseSearchCard)

The audit's three objections to index keys all dissolve under a code key: opponent-searcher determinisation fan-out is the accepted install_card:{code} aliasing; a code key carries no positional information, so there is no deck-order leak (the actor legitimately knows the candidates — the search reveals them, pending_stack_search is shown to the decider); and variable list length is irrelevant without positions. Observer half: the chosen card is publicly revealed on resolution (engine.rs:2677) while unchosen candidates stay hidden (serialize.py:282 blanks them for the non-decider) — and the key only ever names the chosen card, so both trees share it. InstallFoundCard/GripFoundCard need nothing: unit variants whose only defect was the aggregated parent edge.

Correctly aggregated — confirmed no action

AccessCard/TrashCard (access-queue indices into determinisation-shuffled zones) and every payloadless action stay on the type-only fallback.

Definition of done

#[serde(default)] so the wire format stays backward-compatible; legal.rs populates the new codes.

sweep adds none by construction).

observed_action_key, replacing the two-halves phrasing, with the fallback-is-payloadless-only invariant stated.

including: same-code duplicates sharing an edge for one code-keyed action, and an aircheck_run two-server case.

this ticket's comment records the flip once implemented.

cargo test --workspace (from rust/), and uv run pytest -q green.

Comments

2026-08-02: Filed from ticket 43's "audit, don't fix" clause. Marked needs-triage: section A is mostly mechanical but PlayOperation has an open schema question (index vs. card code) and the Corp board-slot rows want a maintainer's confirmation; section B is an unresolved soundness question, not a task.

2026-08-02: Ticket 46 (triaged, ready-for-agent) established a policy that binds this sweep: every per-target key has two halves — the actor's tree gets the full action_key; the blind observer's tree gets what the redacted log reveals, via mcts2::observed_action_key.

2026-08-02: Triaged with the maintainer (grilling session). Section A approved in full including the low-value tail; the two-halves policy refined to the three-case policy above; PlayOperation schema question settled (add card_code); section B resolved with code keys instead of a separate design ticket; maintainer additionally decided the heap-trash log anonymizations are cosmetic (public heap) and should be dropped, which flips Carnivore to a public-outcome code key in both trees, superseding ticket 46's Carnivore observer arm. Rewritten as a single implementation ticket; status flipped to ready-for-agent.

2026-08-02: Implemented as specified. Both tables land in ai::mcts::action_key: six code keys (case 1) and fifteen slot/id keys (case 3), with PlayOperation.card_code and ChooseSearchCard.card_code added behind #[serde(default)] and populated in legal.rs (the latter from the StackSearch decision's candidate list, so stack_search_options now takes the cards rather than just their count). CarnivoreTrash flipped from ticket 43's index key to carnivore_trash:{card_code} and ticket 46's (Corp, CarnivoreTrash) arm is gone from observed_action_key; no observer arms were added, as the policy predicts. serialize.py lost exactly the two cosmetic corp-side heap rules ("Runner discards …" and the Carnivore grip trash) and nothing else.

AccessCard/TrashCard got explicit type-only arms rather than being left to fall through — that is the only way to make the "fallback holds only payloadless actions" invariant literally true, since those two carry an index they deliberately ignore. Both action_key and observed_action_key now document the three-case policy (and the mcts2 module header's two-halves bullet was rewritten to match), with the invariant stated on action_key.

Tests: golden cases for every row of both tables plus a shared-with-observer sweep over all 21, same-code search picks sharing an edge, the aircheck_run two-server case, Carnivore's flip to a code key in both trees, and the access-queue aggregation. The stale use_leech/seamless_target type-only rows left action_key_golden_cases, and blind_corp_aggregates_carnivore_grip_target_keys was replaced. cargo fmt and cargo clippy --workspace -- -D warnings clean; cargo test --workspace 155 passed (150 lib, up from 144, + 5 integration); uv run pytest -q 134 passed after make rust-py.