A Snapshot Hash Is a Contract, Not a Device Test

There is a wonderfully dangerous moment in cross-platform work.
One test produces the same hash you expected.
The hexadecimal string sits there looking official.
It is fixed-width.
It has numbers and letters.
It practically arrives wearing a tiny lab coat.
Then the sentence grows.
“The snapshot hash matched” becomes “the state is canonical,” which becomes “the bridge is correct,” which becomes “Android and iOS behave identically.”
Only the first step may have been measured.
Ttush Push sends Rust-owned game snapshots across a Flutter value bridge.
Each round snapshot and match snapshot carries a deterministic hash over the fields that define it.
That hash is useful.
It catches edited or mismatched values before Rust reconstructs them as game state.
It pins a known position for parity fixtures.
It gives tests a compact way to name the result of a move sequence.
It still does not boot a phone.
The lesson is not that hashes are weak.
The lesson is that integrity, determinism, and runtime parity are three different properties.
Each needs evidence from the reader that can actually observe it.
The bridge carries values, not an engine handle
The Flutter boundary exposes operations such as initialMatch, legalMoves, applyMove, and chooseBotMove.
Those operations accept and return value objects.
Flutter can hold a MatchSnapshot, render it, and send it back to Rust on the next call.
This is a pleasantly simple interface.
It also means the engine receives data that has left the engine.
Between calls, a snapshot may be copied incorrectly, assembled by a fake, produced by stale generated code, or edited by presentation code that has become a little too helpful.
Rust therefore does not treat an incoming snapshot as trusted state.
It recomputes the expected hash before reconstruction:
fn match_state_from_snapshot(snapshot: MatchSnapshot) -> Result<MatchState, String> {
if snapshot.snapshot_hash != match_hash(&snapshot) {
return Err("match snapshot hash does not match its value fields".to_owned());
}
// Reconstruct the validated match state.
}
The nested round is checked too.
If its own hash disagrees with its tiles, pieces, current player, counter-push restriction, or outcome fields, reconstruction stops.
The engine does not silently bless the edited values by turning them into a new canonical state.
That is the contract.
One hash was not enough
The first interesting trap was structural.
A round hash can cover the board and still leave the match editable.
The match adds fields that do not belong to one round:
- the starting-piece layout used for resets;
- each player's round-win count;
- the match phase;
- the round winner and reason;
- the match winner.
Reusing only the round hash would protect a board while allowing the score around it to drift.
So the match hash includes the round's hash and the match-level fields.
The code comments say the reason plainly: the round hash alone would leave the score editable while the board stayed protected.
This gives the value boundary two layers.
An edited round field fails the round check.
An edited score or phase fails the match check.
The test suite exercises both directions.
One test moves a piece inside the incoming round snapshot without updating its round hash and expects the round-level mismatch error.
Another changes the score and later invents a match winner, then expects the match-level mismatch error.
The hash is not decorative metadata.
Every API that reconstructs state passes through the check.
Canonical means the byte order is part of the protocol
The implementation does not serialize a map and hope two runtimes happen to agree about it.
Rust hashes a prescribed byte sequence.
It starts with a type-specific prefix, then feeds fields in a fixed order.
Enums become explicit bytes.
Optional values get presence markers.
Collection lengths are included before collection items.
The final u64 is formatted as 16 hexadecimal digits.
Conceptually:
round hash = prefix
+ current player
+ ordered tiles
+ ordered pieces
+ counter-push restriction
+ winner and reason
match hash = prefix
+ round hash
+ starting pieces
+ score
+ phase
+ winner fields
That ordering is not an implementation footnote.
It is the protocol.
If one runtime iterates pieces in another order, maps one enum differently, or omits a presence byte, it will produce another hash even when a human thinks the position looks the same.
This strictness is useful because “equivalent enough” is exactly the ambiguity a parity fixture should expose.
It also means a hash change is not automatically a game-rule regression.
Changing the canonical encoding changes the identifier even if the rendered board does not move.
The test tells you that the contract moved.
You still need the source diff to tell you why.
A deterministic integrity tag is not a security seal
The snapshot hash is a 64-bit deterministic calculation implemented in the same public source as the rest of the engine.
It is not a keyed message authentication code.
It is not a server signature.
It does not prove that a malicious client could never construct another internally matching snapshot.
That is not the threat this local value boundary is designed to solve.
Its job is to stop Flutter-side code, stale bridge values, and accidental edits from being accepted as if Rust had produced them.
The distinction matters because “tamper detection” can sound larger than the mechanism.
Against accidental or unsanctioned mutation inside the application architecture, recomputing the canonical hash is a useful gate.
Against an adversary who can replace the client or reproduce the public algorithm, it is not authentication.
If the product needed a server-authoritative competitive protocol, the trust boundary would need to move to a server and use an appropriate authenticated design.
No amount of hexadecimal posture can negotiate that promotion by itself.
A pinned hash proves a fixture on the runtime that ran it
The Rust bridge tests apply a fixed sequence of moves.
The initial round hash is pinned as 008d1d43a9eefe72.
After the first move, the pinned round hash is 540736b5048c5f9f.
After the four-move push fixture, it is 7044880ea390e9a8.
That sequence tests more than a pretty string.
The assertions also inspect the current player, the moved piece, the damaged tile, and the immediate counter-push restriction.
At the exact source revision checked for this article, three Rust tests selected by the snapshot filter passed, and the focused bot-policy parity test passed separately.
That establishes host-side behavior for that Rust test binary.
It does not establish that Flutter loaded the generated bridge on Android.
It does not establish that iOS linked the same native library.
It does not establish that integer behavior, code generation, packaging, and runtime initialization survived both platform toolchains.
A host test can verify the engine it runs.
It cannot observe a native runtime it never launched.
The integration fixture is a measurement plan, not a past-tense result
The repository contains an integration test designed for cross-platform parity.
It initializes RustLib, uses the real FrbRulesEngine, and applies the same four moves through Flutter.
Then it expects the pinned round hash and three concrete bot-policy choices.
The hash is the compact position check.
The bot choices extend the fixture into deterministic behavior derived from that position.
The test source even warns that an agreed hash only argues for an agreed bot seed.
Both runtimes still need to preserve integer behavior and walk the position the same way.
This is careful test design.
But a test file existing in the repository is not evidence that it passed today.
For this article, I did not run that fixture on Android or iOS.
So the correct statement is:
The repository defines a cross-platform parity fixture that expects
7044880ea390e9a8 and three pinned bot moves on each real native runtime.
The incorrect statement is:
Android and iOS currently produce the same hash and bot moves.
The first sentence describes source.
The second describes an observation that requires two actual runs.
Grammar can hide a missing device remarkably well.
Parity needs paired evidence
A genuine current parity claim needs at least two independently observed results from the intended surfaces.
For this fixture, that means:
- Build and launch the integration test against the current source revision on Android.
- Record the expected hash and bot-policy assertions passing on that runtime.
- Build and launch the same revision on iOS.
- Record the same assertions passing there.
- Keep the source revision and platform context attached to both observations.
The comparison is the evidence.
One platform passing is useful, but it is not parity.
A Rust host test plus one phone is also not Android/iOS parity.
Two old device runs on another commit do not establish the current revision.
And a green repository gate may still skip native bridge startup entirely.
The question is always: what reader consumed the fixture?
If the reader was a host Rust test binary, you learned about host Rust.
If the reader was an Android app loading the native bridge, you learned about Android.
If nobody launched iOS, the iOS column is not “probably green.”
It is unmeasured.
Keep three verdicts instead of one
I now find it useful to separate the result into three verdicts.
Snapshot integrity
Does Rust reject an incoming snapshot whose hash disagrees with its value fields?
The focused host tests answer this for the tested API paths.
Fixture determinism
Does the same host engine revision produce the pinned hash and bot decisions for the pinned moves?
The Rust parity fixture answers this on the host runtime that executed it.
Native runtime parity
Do Android and iOS, through the real generated bridge and packaged native library, produce the same pinned results?
Only paired native runs answer this.
These verdicts can fail independently.
An encoding change can break the pinned hash while the bridge still launches everywhere.
A packaging error can break one mobile runtime while every host engine test stays green.
A missing hash check can weaken the value boundary even while Android and iOS agree perfectly on the fixture.
One green badge cannot summarize three readers.
The practical rule
Use a canonical snapshot hash to name and validate a value contract.
Define every included field and its order deliberately.
Nest hashes when a larger state owns fields that the inner snapshot cannot cover.
Test that edited fields are rejected, not only that valid fixtures round-trip.
Pin meaningful positions and behavior, not an isolated hexadecimal souvenir.
Then stop the claim where the execution stopped.
If only Rust ran, report host evidence.
If Android ran, report Android evidence.
If Android and iOS both ran at the same revision, compare them and report parity.
A hash is excellent at telling you that a precise contract produced a precise result.
It is very bad at teleporting that result onto a device you never booted.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.