Pulse entry

Deterministic State: How Row Versions Make APIs Predictable

Back to Pulse

Deterministic State: How Row Versions Make APIs Predictable

Every API write is a proposed state transition.

A client believes the system is in state N and asks the server to move it to state N+1. If that assumption is wrong, even by milliseconds, the write should not silently succeed.

Yet most systems still rely on last-writer-wins, a policy that trades correctness for convenience. The result is familiar: lost updates, phantom regressions, and teams debugging ghosts.

There is a better contract.

The Human Cost of Non-Determinism

You’ve seen it:

  • A support ticket marked resolved quietly reopens.
  • A dashboard shows data that “was saved” but never persisted.
  • Two services update the same record and neither knows it overwrote the other.

Nothing technically “broke.” But trust did.

Non-deterministic writes create invisible failures, the worst kind.

The Invariant That Changes Everything

A write is valid only if it starts from the latest committed state.

This single invariant turns concurrency from a guessing game into a rule system.

If a client acts on stale state, the server must say no, explicitly, immediately, and safely.

That’s where row versions come in.

Row Versions as a Timeline

A row version is a monotonic counter stored alongside your data:

  • It increments on every update.
  • It encodes the order of state transitions.
  • It never lies.

Unlike timestamps, it doesn’t drift, skew, or collide. It is the database’s ground truth.

row_version = 1 -> 2 -> 3 -> 4

Each increment represents a committed transition in time.

Projecting Row Versions into HTTP

The elegance appears when you project the row version outward.

  • Reads return it as an ETag
  • Writes must echo it back using If-Match

Read

GET /tickets/42
ETag: "rv:7"

Write

PATCH /tickets/42
If-Match: "rv:7"

If the current version is no longer 7, the server responds:

412 Precondition Failed

No overwrite. No ambiguity. No silent corruption.

Why This Is Deterministic

Every mutation now follows one of two paths:

  1. (state_n, intent) -> state_n_plus_1
  2. Rejected with a version conflict

There is no third option.

The system becomes a state machine, not a suggestion box.

Failure Modes You Eliminate Instantly

  • Lost updates -> impossible
  • Flaky retries -> safe and boring
  • Ghost UIs -> surfaced immediately
  • Race conditions -> explicit conflicts

Conflict is no longer an accident. It is a first-class outcome.

Determinism at Scale (HTTP + gRPC)

The same invariant applies beyond HTTP:

  • HTTP uses ETag / If-Match
  • gRPC carries expected_row_version
  • Event consumers validate before applying mutations

Different protocols. Same rule.

That consistency is what makes automation trustworthy.

Why Timestamps Are Not Enough

Timestamps answer when. Row versions answer which came last.

Clocks drift. Writes race. Versions don’t argue.

If correctness matters, monotonic counters beat wall time every time.

Metrics That Actually Matter

Once version conflicts are explicit, they become observable:

  • Spike in 412 responses -> stale caches
  • High conflict rate -> hot rows or missing UX merges
  • Sudden drop to zero conflicts -> someone bypassed the guardrails

Determinism gives you telemetry that maps to real risk.

Where to Start

  1. Add a row_version column (bigint, default 1).
  2. Return it as ETag on reads.
  3. Require If-Match on all non-idempotent writes.
  4. Fail with 412 when versions mismatch.

That’s it. No locks, no tombstones, no clock gymnastics.

When every write proves lineage, your API stops being polite and starts being correct.