Skip to content
Lesson 14.7Versioned Evidence, State, and Integrity

Integrity Checks and Their Boundaries

An integrity check is meaningful only when its inputs, timing, and decision path cover the state it promises to protect.

Lesson 14.7
Read or listenPrint chapter or book
About 50 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 14.7Key ideas, reference and questions

A one-page summary of “Integrity Checks and Their Boundaries” you can keep beside your notes. Plain Markdown.

Lesson 14.7 — Integrity Checks and Their Boundaries: cheatsheet

  • Remember: Keep verification and the guarded update attached to the same current state; a separate remote read and write cannot promise atomicity.
  • Also useful: An integrity check resembles a dated photograph of a marked area: it says nothing about unpictured fields or changes after the picture was taken.

Reading map

  • An integrity check is a claim about a boundary
  • Follow the decision, not only the checksum function
  • A toy integrity record
  • Keep verification beside the effect
  • Test the relation, not one handpicked example
  • Glossary terms introduced here

Formulas and patterns

  • player_id wrapping_mul(31) 7 × 31 = 217; health + 80; max_health rotate_left(7) 100 × 2^7 = 100 × 128 + 12,800; generation rotate_left(13) 3 × 2^13 = 3 × 8,192 + 24,576; checksum = 37,673

Review

  • Q: A checksum validates a display cache, but the guarded operation changes a separate simulation object. What is the central design failure? A: The check and the effect use different sources of truth
  • Q: Which of the five boundaries asks when bytes were measured and how long the result is reused? A: Time
Download cheatsheet (.md)

Imagine a game displaying a green integrity indicator while an inventory item has already changed. A hash proves something about the byte range and moment it covered; a snapshot may be only a copy of the state the game actually uses. Tracing the state transition and the check’s decision tells us whether the indicator describes the authoritative value.

An integrity check is a claim about a boundary

Section titled “An integrity check is a claim about a boundary”

An integrity check answers a narrow question such as, “Do these bytes equal the expected bytes?” It does not automatically prove that the whole process, file, object, or later operation is unchanged.

Every integrity check has five boundaries, and a passing result means nothing outside them:

Boundary Question to answer
Bytes Exactly which bytes are included?
Time When are they measured, and how long is the result reused?
Identity Which build, object, file, or module does the result describe?
Consumer Which operation trusts the result?
Failure What state is reached when verification cannot complete?

The target invariant for this lesson is:

A sensitive toy operation proceeds only when verification covers the same object, the required fields, and the state used by that operation.

Follow the decision, not only the checksum function

Section titled “Follow the decision, not only the checksum function”

The important path begins with canonical state and ends at the effect:

valid
invalid or unknown
Canonical object
Select covered fields
Compute expected relation
Verification result
Perform guarded effect
Refuse and record reason
Postcondition check

A result that is cached, copied to another object, or checked far from the effect adds edges to this path. Each added edge is a place where the result can stop describing the object, and the moment, it was computed for.

This deliberately small record uses a checksum as an accidental-corruption signal. It is not cryptographic authentication:

#[derive(Debug, Clone)]
struct PlayerRecord {
player_id: u32,
health: u16,
max_health: u16,
generation: u32,
checksum: u32,
}
fn covered_checksum(record: &PlayerRecord) -> u32 {
record.player_id
.wrapping_mul(31)
.wrapping_add(u32::from(record.health))
.wrapping_add(u32::from(record.max_health).rotate_left(7))
.wrapping_add(record.generation.rotate_left(13))
}
fn verify(record: &PlayerRecord) -> bool {
record.health <= record.max_health
&& record.checksum == covered_checksum(record)
}

The checksum includes every field used to identify the record and interpret health. The range invariant is checked separately because a matching checksum does not make an impossible value meaningful.

For the record used in the examples below, player 7 with 80 of 100 health at generation 3, the checksum works out like this:

player_id wrapping_mul(31) 7 × 31 = 217
health + 80
max_health rotate_left(7) 100 × 2^7 = 100 × 128 + 12,800
generation rotate_left(13) 3 × 2^13 = 3 × 8,192 + 24,576
checksum = 37,673

rotate_left(7) moves every bit of the 32-bit word seven places toward the high end, and any bit pushed off the top re-enters at the bottom. For a small number nothing reaches the top. 100 is 1100100 in binary, seven bits wide because it is less than 2⁷ = 128, so after moving seven places its highest bit sits at position 6 + 7 = 13, far below bit 31. The rotation is then exactly a multiplication by 2⁷. The same reasoning turns 3.rotate_left(13) into 3 × 2¹³. The total, 37,673, is far below 2³² (about 4.3 billion), so none of the wrapping operations actually wraps.

player_id7
health80
max_health100
generation3
checksum37,673
inputs to covered_checksum
stored result
verify recomputes a checksum from the first four fields and compares it with the fifth. A field left out of the braced range could change without the comparison noticing.

A correct earlier decision can become stale. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

record = generation 17, health 80
verify and save checked_generation 17
optionally update record to generation 18
compare checked generation with current
consume the decision only when current
report whether the effect was permitted

Result: the current decision permits the fixture effect.

Final values: update = unchanged; generation = 17; health = 80; effect = true; checked_generation = 17; earlier_check = true; decision_current = true.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    update = unchanged.

  2. Step 2, line 1. Start with one named record.

    update = unchanged; generation = 17; health = 80; effect = false.

  3. Step 3, line 2. The check is correct for generation 17.

    update = unchanged; generation = 17; health = 80; effect = false; checked_generation = 17; earlier_check = true.

  4. Step 4, line 3. The checked record stays unchanged.

    update = unchanged; generation = 17; health = 80; effect = false; checked_generation = 17; earlier_check = true.

  5. Step 5, line 4. Compare 17 with the current generation.

    update = unchanged; generation = 17; health = 80; effect = false; checked_generation = 17; earlier_check = true; decision_current = true.

  6. Step 6, line 5. The decision still refers to this state.

    update = unchanged; generation = 17; health = 80; effect = true; checked_generation = 17; earlier_check = true; decision_current = true.

  7. Step 7, line 6. Result: the current decision permits the fixture effect.

    update = unchanged; generation = 17; health = 80; effect = true; checked_generation = 17; earlier_check = true; decision_current = true.

A check covers health but ignores max_health or player_id. The bytes can be internally consistent while describing the wrong entity or an impossible range.

Watch it happen to the record above. Suppose covered_checksum had been written without the max_health term — an easy omission, since the field is rarely the one being updated. For player 7 that shorter sum is 217 + 80 + 24,576 = 24,873, and 24,873 is what the record stores. Now change max_health from 100 to 60 and recompute nothing:

Predict first

The record stores the shorter checksum, 217 + 80 + 24,576 = 24,873. Then max_health drops from 100 to 60, and nothing is recomputed.

When verify recomputes the shorter checksum, does it still equal the stored 24,873?

Answer: Yes: it is still 217 + 80 + 24,576 = 24,873. None of the three terms reads max_health, so the recomputed sum equals the stored one. verify still refuses this record, but only because its second clause finds 80 > 60.

Change a field, then run verifyThe record was sealed as player 7, health 80, max_health 100, generation 3. verify recomputes the checksum from the record now and also tests health ≤ max_health.

Recompute, compare, decide

Recompute from the record now

Terms
217player_id × 31
+ 80health
not coveredmax_health 60
+ 24,576generation 3 × 8,192
Compare
24,873recomputed now
=
24,873stored when sealed

verify: both clauses, then the result

Clauses
checksum matches
80 ≤ 60: no
Result
verify refuses: OutOfRange

217 + 80 + 24,576 = 24,873, equal to the stored 24,873, because max_health is not in the sum. 80 > 60. verify refuses with OutOfRange.

The checksum is doing its job correctly. It is reporting that every byte it covers is unchanged, and that is true. The record is still impossible, because the damage was done to a field the check never looked at.

The complete covered_checksum catches the same edit. With max_health = 60 its third term becomes 60 × 128 = 7,680, so the sum becomes 217 + 80 + 7,680 + 24,576 = 32,553, which no longer equals the stored 37,673.

This is why verify has two clauses rather than one. The checksum answers “did the covered bytes change?” and the range test answers “is this record possible?”, and neither question implies the other. A record can pass one and fail the other in both directions, so a check that asks only the first will accept states the game’s own rules forbid.

The code verifies a display cache, then applies an operation to the canonical simulation object. The check and effect refer to different state.

The program verifies generation 17, the object changes to generation 18, and a later operation trusts the old true. This is a time-of-check/time-of-use problem.

Guarded operationOther codePlayer recordCheckerGuarded operationOther codePlayer recordCheckertrue described generation 17, but the record is now generation 18verify covered fields at generation 17validhand over the cached result trueheal, generation becomes 18apply effect, trusting true

The result was correct when it was computed. It went stale because nothing tied it to the generation it described. A consumer that compares the result’s generation with the record’s current generation refuses it at the last step.

A read error, unsupported build, and genuine mismatch all become false or, worse, silently become “valid.” Use explicit results:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IntegrityFailure {
WrongBuild,
Unreadable,
IdentityChanged,
OutOfRange,
ChecksumMismatch,
}

These variants are useful telemetry and make tests state exactly which promise failed.

A guarded update can consume the same mutable object it verifies:

fn apply_heal(record: &mut PlayerRecord, amount: u16) -> Result<(), IntegrityFailure> {
if record.health > record.max_health {
return Err(IntegrityFailure::OutOfRange);
}
if record.checksum != covered_checksum(record) {
return Err(IntegrityFailure::ChecksumMismatch);
}
// The checked object is also the object changed below.
record.health = record.health.saturating_add(amount).min(record.max_health);
record.generation = record.generation.wrapping_add(1);
record.checksum = covered_checksum(record);
Ok(())
}

This local function is easy to reason about because the check and effect share one exclusive borrow. Across process or thread boundaries, use versioned snapshots, expected-byte comparisons, or a command sent to the owner of the state; a remote read followed by a remote write is not atomic.

Test the relation, not one handpicked example

Section titled “Test the relation, not one handpicked example”

Useful regression properties include:

  • changing any covered field without updating the checksum is rejected;
  • changing an uncovered display-only field does not affect verification;
  • health > max_health is rejected even with a recomputed checksum;
  • a stale generation is refused at the consuming boundary;
  • read failure produces Unreadable, never “valid”;
  • every refusal emits a bounded reason code and no state change.

For the small record above, iterate over each covered field and flip one bit. That mutation test proves the test suite notices missing coverage.

  • Integrity: confidence that state still satisfies its required relation.
  • Coverage: the exact data and conditions examined by a check.
  • Canonical state: the state the game actually uses to decide an effect.
  • Freshness: whether a check result still describes the state being consumed.
  • TOCTOU: a gap in which state changes between checking and using it.
  • Postcondition: a property that must hold after an operation completes.

An integrity result describes only the bytes it covered, the identity and time of that check, and the consumer that acts on it. A missing field or stale result breaks the link to the guarded effect. The mutation test above exposes missing coverage; the repair puts verification where the current state becomes an effect.

Knowledge check

Check what the effect consumes

A checksum validates a display cache, but the guarded operation changes a separate simulation object. What is the central design failure?

Finished Lesson 14.7?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 14: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.