← Back to blog2026-08-24

Where fairness proof belongs: the architecture behind removing one button

A change that looked like deleting a button was really about where a trust promise belongs: review explains the hand, while the fairness page owns exported commit-reveal proof. The reusable move is testing the absence so broad claims cannot drift back onto the wrong screen.

The bug was not a button

The easy version of the story is that we removed a share/export button from the hand-review screen. That is true, but it is not the useful lesson.

The real bug was information architecture: we had put a trust claim next to a screen whose job was something else. Review should explain one finished hand. Fairness verification should prove one exported record. Mixing those two jobs made the interface look more capable than the guarantee underneath it.

The patch behind this post is merge 4664ff06, fix/review-fairness-dup. Its first-parent diff reduced client-game/src/views/ReviewView.vue by 336 net lines, deleted the review-screen fairness export test, added a focused information-architecture test, and moved the proof workflow into client-game/src/views/tools/FairnessVerifyView.vue. The reusable rule: put the promise where the proof actually lives.

Separate the promise from the place people are looking

A hand-review screen is emotionally hot. The player just won, lost, or got confused, and wants to know what happened. That makes it a dangerous place to put a big trust label. The label may be true in one technical sense, but the user reads it as a broader guarantee.

In our case, the review screen used to say players could see the hand record, action context, and fairness evidence. After the change, its own top comment says the off-by-default review view shows only the hand record and action context (ReviewView.vue:9-10). The fairness proof path is not gone; it moved to the surface that can explain the proof honestly.

That split matters because a standard commit-reveal proof is not server-blindness. It proves committed-before and unaltered-after. It does not prove the server never saw the cards. The standalone verifier states that boundary before any UI is rendered: it verifies exported ADR-064 HandRecord JSON, and explicitly does not claim server-blindness (FairnessVerifyView.vue:3-8).

Make the proof surface self-contained

Moving a claim is not enough. If users now need another page, that page has to carry the whole path, not just a textarea.

The verifier page does four jobs now. Signed-in players can choose a recent hand or enter a hand id manually, while anonymous users can upload an already-exported record (FairnessVerifyView.vue:60-68). It can fetch one historical hand record by id for an authenticated participant. It can download the exact proof JSON under the same filename shown in the CLI command. And it can run the browser convenience check against /api/tools/poker/fairness/verify.

The backend route is deliberately public and stateless. The server comment describes it as a wrapper around the same commit-reveal verifier shipped as pf_verify, and says it only receives JSON proof the user already exported from a finished hand (server/src/handlers/poker_tools.rs:142-153). That phrasing is not decoration; it is the product boundary.

Test the absence, not just the feature

The most important test in this change is not "the fairness page works." It is "review no longer pretends to be the fairness page."

ReviewView-information-architecture.test.ts mounts the hand-review surface and asserts three absences: no export-fairness block, no generic export button, and no visible "provably-fair proof" wording. It also asserts the coach CTA remains, because removing the wrong promise should not flatten the rest of the hand-review workflow (ReviewView-information-architecture.test.ts:104-123).

The verifier page then gets its own positive tests. One checks that recent history can produce a hand id. Another proves anonymous users stay on the verifier with the upload path available when the recent-hand lookup returns 401. Another filters engine-blind hands out of the commit-reveal picker, because that is a different proof family. Another confirms the downloaded filename matches the CLI hint (FairnessVerifyView.test.ts:66-155, 184-215).

The reusable pattern

Any product that has a trust claim can use the same checklist:

  1. Name the proof. Is it commit-reveal, audit log, signed transcript, deterministic replay, or something else?
  2. Name what it does not prove. If the page cannot say this plainly, the claim is too broad.
  3. Keep the proof next to the action that runs it. A badge on a high-emotion screen is weaker than a boring verifier with inputs, outputs, and failure states.
  4. Write an absence test. The regression is usually the claim drifting back into the wrong screen.

That last point is the one I keep seeing agents miss. They write tests for the new page and forget to lock the old page down. Then a later cleanup reintroduces the old copy because it "sounds helpful." Absence tests are how you make a product promise stay narrow.

Recap

  1. The mistake: a hand-review screen carried a fairness-proof affordance, which made a narrow proof look like a broader review guarantee.
  2. The fix: Review owns hand record and action context; /fairness owns exported commit-reveal proof loading, verification, download, and CLI handoff.
  3. The guard: test both sides: the proof page works, and the review page does not contain the proof promise anymore.
  4. The reusable lesson: put the promise where the proof actually runs, and make every other surface earn its silence.