---
title: defHash byte-level spec — recomputing a game's fairness commitment from published data
status: normative
implements: packages/engine/src/registry/def-hash.ts, packages/engine/src/registry/canonical-json.ts
links: [.rea/knowledge/engine-game-contract.md §7 §9, .rea/knowledge/invariants-outside-the-type-contract.md, docs/rng-wire-spec.md]
---

# defHash byte-level spec

This document is **normative**, not a summary of the code. It exists because Stage 0's exit gate
(condition 2) requires an outsider to independently recompute `defHash` from published data, and
[U10](../.rea/plans/0001-stage-0-foundations/todo.md) computes the value while publishing only the
result — nothing before this document said, byte for byte, how to reproduce it. `docs/rng-wire-spec.md`
is the standard this document is held to: normative prose plus real, code-checked vectors, written so
a from-prose reimplementation in any language lands on byte-identical output.

If you are that outsider: everything below lets you reproduce `defHash` in any language with SHA-256
and ordinary string/byte handling — no dependency on this project's own code — checkable against the
published vectors at the bottom.

**Where this document is authoritative, and where it is not.** For the byte-level algorithm — what
gets canonicalized, what gets concatenated, in what order, as which encoding — if
`packages/engine/src/registry/def-hash.ts` or `canonical-json.ts` disagrees with this document, that
is a bug in the code, not in this document. Field names and function signatures are not this
document's concern; `packages/engine/src/contract/publication.ts`'s `ProofBundle` type is the source
of truth for those.

## What `defHash` is, and what it deliberately is not

`defHash` is the fairness commitment for one `(gameId, version)` — engine-game-contract.md §7, §9:

> SHA-256 over RFC 8785 (JCS) canonical JSON of `{ config, timing }`, concatenated with the
> build-time content hash of the compiled game module.

Two things are excluded on purpose:

- **`limits`** — admission control (stake ceilings, exposure caps), not fairness. An operator must be
  able to retune `minStake`/`maxStake` without invalidating every proof bundle already published under
  that version.
- **`id`, `version`, and every function** (`deriveOutcome`, `settleStake`, ...) as *declared* values —
  the functions are not JSON and cannot be canonicalized; instead their compiled behaviour is folded
  in indirectly, via the module content hash (below). A code change with **no** config/timing change
  still moves `defHash`, because it moves the module content hash.

**A THIRD exclusion, stated explicitly here for the first game it applies to: a game's own sibling
modules that decide no outcome are outside the module content hash, on purpose.** The module content
hash (Step 2) covers only the bytes of the ONE resolved game-definition file itself — crash's own
`crash.ts` — never the bytes of anything that file merely `import`s. Crash's growth curve and its
inverse (`packages/engine/src/games/crash-curve.ts`, U4) is exactly this shape: `crash.ts`'s
`liveDurationMs` calls into it, but the curve is a pure function of an ALREADY-COMMITTED `crashCenti`
(`deriveOutcome` decides that value first, independently) and decides no outcome of its own — it only
paces how long a round's live phase visibly runs. **`defHash` commits the outcome-deciding code — what
decides who wins and what they are paid — never the round's animation timing.** A future retune of the
curve's pacing constants (making rounds visually run faster or slower for the SAME `crashCenti`) moves
no player's win or loss and therefore correctly leaves every published `defHash` for `(ascent, 1)`
unchanged; only `crash.ts`'s own bytes, or `config`/`timing`, moving the hash would be the property this
document's verification loop depends on.

## Inputs

| Name | Type | Notes |
|---|---|---|
| `config` | JSON value | The game definition's `config` field — data only (AGENTS.md: "config as data"), never a function. |
| `timing` | JSON value | The game definition's `timing` field (`TimingConfig` — engine-game-contract.md §5). |
| module bytes | raw bytes | The bytes of whichever file `import.meta.resolve('@corbagames/engine/games/<id>.js')` resolves to at registration time — see "The compiled game module," below, for why that is NOT always the same file. |

## Step 1 — canonicalize `{ config, timing }`

Build a single JSON value `{ "config": <config>, "timing": <timing> }` and serialize it per
[RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) (JCS — JSON Canonicalization Scheme):

- Object members are ordered by comparing their names as **UTF-16 code unit sequences** — exactly
  what a plain, comparator-less array sort does in a language whose native string comparison is
  UTF-16-code-unit-wise (JavaScript's `Array.prototype.sort()` is; most languages need an explicit
  UTF-16 comparison, not a byte or codepoint one, to match this).
- Strings are escaped exactly as ordinary JSON string escaping (the same rules `JSON.stringify`
  applies in JavaScript).
- Numbers are serialized via the shortest round-tripping decimal representation (ECMAScript's
  `Number::ToString`) — an integer has no trailing `.0`; `-0` serializes as `0`.
- No whitespace anywhere — no spaces after `:` or `,`, no newlines.

This project's own implementation (`registry/canonical-json.ts`) is under 40 lines and does exactly
this, recursively, with no third-party JCS library — reproducing it from these four rules, in any
language, is a small, self-contained exercise. The result is a **JSON text string**, not yet hashed.

**Worked example.** `{ config: { winThreshold: 50 }, timing: { kind: "onDemand", livePhase: { kind: "none" } } }`
canonicalizes to exactly this string (87 characters, and 87 bytes in UTF-8 since every character is
ASCII; no leading/trailing whitespace, shown here
with explicit quoting so nothing is ambiguous):

```
{"config":{"winThreshold":50},"timing":{"kind":"onDemand","livePhase":{"kind":"none"}}}
```

Note the key order: `config` before `timing` at the top level (`"c" < "t"`), and inside `timing`,
`kind` before `livePhase` (`"k" < "l"`) — both are plain UTF-16 lexicographic ordering, nothing
game-specific.

## Step 2 — the module content hash

```
moduleContentHash = lowercase-hex(SHA-256(bytes of the resolved game module))
```

**"The compiled game module" — corrected.** An earlier version of this document said the hashed bytes
are always "the module's raw source bytes on disk," justified by "Stage 0 has no separate
compile-and-bundle step for an individual game." **That justification holds for a plain repository
checkout and is false for the artefact that actually publishes bundles from a real deployment** — see
`.rea/knowledge/defhash-commits-the-resolved-module-bytes.md` for the measurement this correction is
based on, made against the live host rather than assumed.

The bytes hashed are exactly whatever file `import.meta.resolve('@corbagames/engine/games/<id>.js')`
resolves to, at the moment the game registers (`registry/registry.ts`'s `register()`, called from
`apps/server/src/index.ts`) — and that specifier resolves to a DIFFERENT file depending on which
`package.json` manifest governs the resolution:

- **A repository checkout (this project's own `pnpm test`/`vitest`, and the reproduction transcript
  below).** `packages/engine/package.json`'s `exports` map (`"./games/*.js": "./src/games/*.ts"`)
  sends the resolver to the game's own TypeScript **source** file.
- **The deployed runtime image.** `apps/server/scripts/write-engine-runtime-manifest.mjs` writes an
  `exports`-**less** manifest into the image at `node_modules/@corbagames/engine/package.json`, so
  the SAME specifier resolves the plain, unrestricted Node way onto the **compiled** `dist/games/
  <id>.js` — the TypeScript build's own output, never the source.

Both are real files that genuinely get hashed in their own context; which one a particular published
`defHash` was computed over depends entirely on which process computed it, never on anything this
document alone can tell a reader in the abstract. Measured directly against the live deployed host:
`sha256(packages/engine/dist/games/testgame.js)` reproduced a published `defHash`;
`sha256(packages/engine/src/games/testgame.ts)` — the literal reading of the old "raw source bytes on
disk" claim — did not.

**Verification still works end to end regardless of which artefact was hashed**, because `GET
/public/games/:gameId/:version/module` (this document's own "Reachable with curl" section, below)
serves the EXACT SAME bytes `computeModuleContentHash` read at registration — an outsider who fetches
the served module and hashes it always reproduces the published `defHash`, whichever artefact that
happened to be. What was wrong was narrower than the mechanism itself: a reader who followed this
document's OLD text literally, cloned the repository, and hashed the `.ts` **source** directly (rather
than fetching the served module) would get a number that does not match a `defHash` published by the
deployed image, with no way to tell, from the document alone, whether the operator had cheated or the
document had lied. This section is the correction. Do not re-derive "raw source bytes on disk" as a
universal claim from anywhere else in this document — the "Resolved ambiguities" summary at the bottom
is corrected to match.

A future minifier or bundler changing `dist`'s bytes without changing behaviour would still move
`defHash` for no fairness-relevant reason — that residual gap is unaffected by this correction and
stays open, exactly as it did before it.

`moduleContentHash` is a 64-character lowercase hex string (the standard hex digest encoding of a
32-byte SHA-256 output).

**Checked, not assumed: does a Windows checkout disagree with the deployed Linux image about line
endings?** U23's own task explicitly flagged this as a real risk worth checking — "Windows checkouts
and Docker images can disagree about line endings... if that bites, it is a real finding about
`defHash`'s stability, not something to paper over." It was checked, twice, independently: this
repository's own `.gitattributes` pins `* text=auto eol=lf` for every text file, and the ONLY checkout
that ever feeds a real deployment is CI's own `actions/checkout` on an Ubuntu runner building the
Docker image — never a developer's local Windows working tree (`core.autocrlf=true` locally does not
matter, because no Windows-hosted process ever runs `apps/server` in production). `apps/server/src/
routes/public/defhash-reproduction.live.test.ts` passed on this Windows workstation (self-consistent —
both sides of that comparison read the SAME local checkout, so a CRLF/LF choice cancels out either
way), and the SAME recomputation was independently re-run against real bytes fetched off the deployed
VPS (`docs/runbooks/deploy.md`'s own U23 section) and matched byte-for-byte. **The gap is real in
principle and does not bite in this project's actual pipeline**, for the specific reason above — not
because the risk doesn't exist, but because this repository's line-ending policy and its single,
CI-only deployment path both already close it. It would resurface if a game module were ever compiled
or registered by a Windows-hosted server process, which nothing in this plan does.

## Step 3 — concatenate and hash again

```
defHash = lowercase-hex(SHA-256(utf8(canonicalJson) || utf8(moduleContentHashHex)))
```

**Byte encoding — resolved ambiguity, the same trap `docs/rng-wire-spec.md` names for its own key
encoding.** `moduleContentHashHex` is a **hex string** (64 ASCII characters, e.g.
`"dfcc95c8...ef218d"`). There are two ways to turn it into bytes before hashing:

1. **UTF-8-encode the hex text itself** — the ASCII bytes of the characters `'d'`, `'f'`, `'c'`, `'c'`,
   … — 64 bytes for a 64-character hex string.
2. **Hex-decode** it back to the 32 raw bytes the hex text represents.

**This project uses (1): UTF-8-encode the hex string**, exactly the same convention
`docs/rng-wire-spec.md` documents for `serverSeed` and `packages/engine/src/rng/chain.ts` documents
for hashing one hash-chain link forward into the next — one encoding rule project-wide, applied
consistently everywhere a SHA-256 digest's hex form is fed back into another hash operation, not a
choice made freshly per module. `canonicalJson` (Step 1's output) is UTF-8-encoded the ordinary way — it
is already a plain JSON text string with no hex-string ambiguity of its own.

**This matters enormously and silently, exactly as it does for the RNG's key encoding**: hex-decoding
`moduleContentHash` instead of UTF-8-encoding it produces a completely different, unrelated `defHash`,
with no error or warning either way. Both are 32-byte and self-consistent-looking SHA-256 inputs. A
reimplementer who defaults to "of course you decode the hex, it's a hash" will get a plausible,
**wrong** answer. Concretely, hashing the exact worked example below the WRONG way (hex-decoding the
32 raw bytes of `moduleContentHash` instead of UTF-8-encoding its 64 hex characters) produces
`f00321152d51b0429c69cf300b36edc98ccaebff8abad1e04c6b988e9b3ed298` — sixty-four hex characters, looks
completely legitimate, and is not the published `defHash` below.

The concatenation itself is a plain byte-string concatenation — `utf8(canonicalJson)` immediately
followed by `utf8(moduleContentHashHex)`, no separator, no length prefix — then that single combined
byte string is SHA-256-hashed once, and the 32-byte result is lowercase-hex-encoded as `defHash`.

## Worked example, in full

Module: `packages/engine/src/registry/__fixtures__/valid-game.ts` (committed in this repository,
2,286 bytes on disk at the revision these vectors were generated from).

```
moduleContentHash = sha256(<raw bytes of valid-game.ts>)
                   = dfcc95c8bca914d05f92557d5fe36e303c075993da130f3b571309e759ef218d

config = { "winThreshold": 50 }
timing = { "kind": "onDemand", "livePhase": { "kind": "none" } }

canonicalJson = {"config":{"winThreshold":50},"timing":{"kind":"onDemand","livePhase":{"kind":"none"}}}
              (87 bytes, UTF-8 — every character above is ASCII, so byte count equals character count)

defHash = sha256(utf8(canonicalJson) || utf8(moduleContentHash))
        = 7ea523e73ef8802ff1eecc8094d97064045bd2fcad84ef1b0d72e0b8d2defac4
```

An outsider can reproduce this end to end with nothing but `valid-game.ts`'s published bytes, a
SHA-256 implementation, and the three steps above.

## Test vectors

`packages/engine/src/publication/defhash-vectors.test.ts`, shipped alongside this document, checks
every value below against the live `computeDefHash`/`computeModuleContentHash`
(`packages/engine/src/registry/def-hash.ts`) on every `pnpm test` run — the same discipline
`docs/rng-wire-spec.md`'s fix-cycle 2 added after finding its own `primary` fixture and
`weightedExample` were published but checked against nothing. A future change to `canonicalizeJson`
or `computeDefHash` that moves any of these numbers fails that test, rather than silently leaving this
document stale.

All three vectors hash the **same module** (`registry/__fixtures__/valid-game.ts`,
`moduleContentHash = dfcc95c8bca914d05f92557d5fe36e303c075993da130f3b571309e759ef218d`), so the only
thing that varies between them is `{config, timing}` — which is the whole point: the same module
produces a different `defHash` for a different config, and the module hash contribution is fixed and
checkable on its own.

| # | `config` | `timing` | `defHash` |
|---|---|---|---|
| 1 | `{"winThreshold":50}` | `{"kind":"onDemand","livePhase":{"kind":"none"}}` | `7ea523e73ef8802ff1eecc8094d97064045bd2fcad84ef1b0d72e0b8d2defac4` |
| 2 | `{"winThreshold":60}` | `{"kind":"onDemand","livePhase":{"kind":"none"}}` | `4f3310a147011391a4d9639e7ae6a1e6b8b0bc5f9a276989a8ef6af936a05e7c` |
| 3 | `{}` | `{}` | `dafcbad5b2645a9e42d05aff76242d24e4afb589be3d9787c0666fa7d57e5190` |

Vector 1 → 2 is the exact shape engine-game-contract.md §9's assertion 1 exists to catch: the same
module, `config.winThreshold` changed from 50 to 60, and `defHash` moves — the CI snapshot test
(`registry/defhash-snapshot.test.ts`) is what refuses that change for an EXISTING `(gameId, version)`
without a version bump; this document only shows that the hash genuinely does move, independent of
that guard. Vector 3 is the smallest possible input (`config`/`timing` both the empty object) —
included so a reimplementation that mishandles the empty-object case (e.g. emitting `{ }` with a
space, or `null` instead of `{}`) has something concrete to fail against.

## How this feeds the public config endpoint

`ProofBundle.configUrl` (`packages/engine/src/contract/publication.ts`) serves the **exact
`canonicalJson` text from Step 1** for a resolution's `(gameId, gameVersion)` — byte for byte, not
re-parsed and re-serialized through a generic JSON encoder, because a generic encoder is not
guaranteed to preserve JCS's key ordering (a plain-JS `JSON.stringify` on a re-parsed object, for
instance, reorders any integer-like string key numerically ahead of every other key, which is NOT
JCS's UTF-16-lexicographic rule — see `canonicalizeJson`'s own test for a worked example where those
two orderings genuinely disagree). `apps/server/src/routes/public/config.ts` writes the canonical
string straight to the response body for exactly this reason. An outsider fetching `configUrl`,
computing `sha256(utf8(body) || utf8(moduleContentHash))`, and comparing against the resolution's
published `defHash` is the whole verification — everything this document describes, run against real
served bytes rather than a local file.

**Superseded by U23 — the module bytes ARE now served.** Everything in this section down to here
described a real gap, found and recorded honestly; it is being corrected rather than deleted, because
the history of "the claim was false, corrected here" one paragraph down is itself worth keeping
legible. `GET /public/games/:gameId/:version/module` (`apps/server/src/routes/public/module-bytes.ts`
— named to avoid a real, confirmed TypeScript compiler crash a source file literally named `module.ts`
triggers inside `dependency-cruiser`'s type-aware extraction; see
[[typescript-computeModuleSpecifiers-module-filename-crash]])
serves the SAME `RegisteredGame.modulePath` bytes `computeModuleContentHash` reads at registration —
byte-for-byte, no re-encoding — so `moduleContentHash` above is now independently reproducible by an
outsider from published data, not only from a committed fixture this document ships with. The
repository itself is still private (unaffected by this — see "What is not decided here" in
[[0025-exit-gate-2-has-three-blockers-not-one]]); what changed is that the module source no longer
NEEDS the repository to be public, because it is separately, deliberately served on its own.

**This is true of the endpoint, not unconditionally true of the deployment — stated precisely so this
paragraph does not overclaim to a stranger reading it off `/public/docs/defhash-spec.md`.** Both
`/public/games/:gameId/:version/module` and `/public/games/:gameId/:version/config.json` serve only a
game currently present in the running server's `GameRegistry` (`apps/server/src/index.ts`'s
`register()` calls at boot) — a request for any other `gameId` answers the same generic
`{"error":"not-found"}` an unknown resolution id gets, the "Reachable with curl" section below
demonstrates this directly for its own fixture game. At Stage 0 the only game that ever registers is
`testGame`, and its registration is itself gated behind `TESTGAME_ENABLED`, **off by default** on the
deployed `corba.readev.co` environment (`docs/runbooks/deploy.md`'s U23 section records the resting
container answering `{"error":"not-found"}` for `/public/games/testgame/1/config.json` with the flag
unset). A visitor following this document's own instructions against the resting deployment hits that
404 on the compiled-code half in the normal case, not a broken endpoint — the capability is real, the
deployment's current registry contents are what gate it.

An earlier draft of this paragraph claimed the source was readable "in this project's own public git
history", by analogy with how `docs/rng-wire-spec.md`'s external verification reached bustabit's
source. **That claim was false and is corrected here.** The repository is private
(`gh repo view aliyenidede/corbagames --json visibility` reports `PRIVATE`), so a genuine outsider —
the stranger with a shell this document is written for — can reach the module source through neither
the API nor git. It reaches it through `/public/games/:gameId/:version/module` instead, which needs
no git access and no repository visibility change at all.

**What this closes, and what it does not.** [[0025-exit-gate-2-has-three-blockers-not-one]] named
three independent blockers to exit-gate condition 2. U23 (this endpoint, plus
`/public/docs/rng-wire-spec.md`, `/public/docs/defhash-spec.md` and `/public/docs/test-vectors.json`,
and `/public/*` actually routed at `corba.readev.co`'s edge) closes blockers 1 and 2: the spec, the
vectors and the module bytes are now genuinely reachable by a stranger with a shell, over `curl`, from
the deployed host — see "Reachable with curl" below for real, observed output. **Blocker 3 is a
separate concern this document does not resolve**: for an on-demand (player-scope) resolution, the
server seed stays `sealedUntil: 'chain-rotation'` until the chain actually rotates, and rotating it is
a different capability from anything `defHash` or this endpoint touch. Do not read "the module bytes
are served" as "every resolution is verifiable end to end" — the outcome-recomputation method and the
fairness-commitment method (this document) are fully reproducible now; whether a given resolution's
*seed* has been revealed yet is an orthogonal, per-resolution fact.

## Reachable with curl — U15's two endpoints, real output

"An outsider can independently verify a finished resolution from published data" (`docs/rng-wire-spec.md`'s
own framing of the exit gate) means a stranger with a shell, not a stranger who trusts a web page.
All four endpoints `apps/server/src/routes/public/` exposes are unauthenticated `GET`s — no cookie, no
header, no token — mounted under `/public` (and, since U23, also at the root — see that unit's own
comment on `apps/server/src/index.ts` for why) on the game server. Since U23, `/public/*` is routed at
`https://corba.readev.co`'s edge, from the SAME origin as `apps/web` — see "U23 — live, off-host
verification" below for the real, from-this-workstation `curl` transcript against the deployed host,
not a tunnel.

```
GET /public/resolutions/:resolutionId    ->  ProofBundle (this document's Step 1's canonicalJson +
                                              Step 2's module hash together produce this resolution's
                                              defHash field)
GET /public/games/:gameId/:version/config.json
                                          ->  the LITERAL canonicalJson text (Step 1) for that
                                              (gameId, version) — Content-Type: application/json
GET /public/games/:gameId/:version/module
                                          ->  the RESOLVED game module's raw bytes (Step 2's input —
                                              source in a repository checkout, compiled dist output
                                              inside the deployed image; see Step 2's own correction),
                                              U23 — Content-Type: text/plain
GET /public/docs/rng-wire-spec.md, /public/docs/defhash-spec.md, /public/docs/test-vectors.json
                                          ->  this document, docs/rng-wire-spec.md and
                                              packages/engine/src/rng/test-vectors.json themselves,
                                              U23 — the spec IS reachable at a URL now
```

The two below are U15's original pair, kept as originally recorded (fix cycle 1, against a local
process) — U23's own live, off-host transcript for all four is further down.

Real, observed output (U15's own verification, re-run for this document during fix cycle 1 — two
calls against two separate local processes, not one, and here is why). The first three calls are all
against the SAME real `apps/server` process (port 47001), pointed at a locally seeded database with
one real settled resolution. The fourth call is against a second, small harness (port 47002) that
mounts the same, real, committed `createPublicConfigRoute` with one game registered — needed because
`apps/server`'s own boot-time `GameRegistry` is intentionally empty until a real game exists to
register (U14), which the THIRD call below demonstrates directly: the real server's own config
endpoint 404s even for a `gameId` that genuinely has a published bundle on that same process.

```
$ curl -s -i http://127.0.0.1:47001/public/resolutions/f857243d-1f93-412a-9494-8a77e42a386d
HTTP/1.1 200 OK
Content-Type: application/json

{"resolutionId":"f857243d-1f93-412a-9494-8a77e42a386d","gameId":"fix1-curl-demo-game","gameVersion":1,
"defHash":"7ea523e73ef8802ff1eecc8094d97064045bd2fcad84ef1b0d72e0b8d2defac4",
"configUrl":"/public/games/fix1-curl-demo-game/1/config.json", ...,
"sealedUntil":"chain-rotation","serverSeed":null,"status":"settled","outcome":{"roll":2},
"bytesConsumed":1}

$ curl -s -i http://127.0.0.1:47001/public/resolutions/00000000-0000-0000-0000-000000000000
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":"not-found"}

$ curl -s -i http://127.0.0.1:47001/public/games/fix1-curl-demo-game/1/config.json
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":"not-found"}

$ curl -s -i http://127.0.0.1:47002/public/games/fix1-curl-demo-config-game/1/config.json
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 87

{"config":{"winThreshold":50},"timing":{"kind":"onDemand","livePhase":{"kind":"none"}}}
```

The third and fourth calls above are the SAME route (`createPublicConfigRoute`), hit on two different
processes with two different registries — the difference in outcome (404 vs 200) is entirely the
registry's own contents, not a difference in the route's behaviour, which is exactly what the two
calls exist to show.

The `defHash` the bundle endpoint returned (`7ea523e7...`) is byte-identical to this document's own
"Worked example, in full" section above — both were produced from the same `{config, timing}` and the
same `valid-game.ts` module, one via the real HTTP path, one via calling `computeDefHash` directly.
That agreement is the whole verification loop closed: fetch the bundle, fetch the config it points at,
hash it per this document, compare against the bundle's own `defHash` field.

A `sealedUntil: 'chain-rotation'` bundle's `serverSeed: null` (shown above) is not a failure — it is
the honest, correct answer for a player-scope resolution whose chain has not yet rotated past its
link. See `.rea/knowledge/invariants-outside-the-type-contract.md` §1 and
`packages/engine/src/publication/bundle.ts` for why that seal exists and how it is enforced; this
document only covers `defHash`, not the seed-reveal guard.

## U23 — live, off-host verification: module bytes, docs, and the demonstrated recomputation

`docs/runbooks/deploy.md`'s own U23 section records the full, real transcript — `curl` run from this
workstation against `https://corba.readev.co`, not through an SSH tunnel, not on the VPS itself — for
all four `/public/*` endpoints, including the module-bytes and docs routes this unit adds. The
byte-for-byte recomputation this document describes is additionally proven as a real, committed,
automated test — `apps/server/src/routes/public/defhash-reproduction.live.test.ts` — which fetches a
real settled resolution's bundle, config and module bytes through the actual `createPublicRoutes` Hono
app, recomputes `defHash` from nothing but that response data, compares it against the bundle's own
`defHash` field, and then deliberately corrupts one byte of the served module to confirm the
comparison actually disagrees before restoring it and confirming agreement again — "demonstrated
rather than asserted," run for real rather than claimed.

## Resolved ambiguities — a summary

1. **`moduleContentHash`'s hex text is UTF-8-encoded, not hex-decoded**, before the final
   concatenation and hash — the same convention `docs/rng-wire-spec.md` uses for `serverSeed` and
   `rng/chain.ts` uses for hashing one hash-chain link into the next. See "Step 3" above for the
   concrete wrong-answer contrast.
2. **"The compiled game module" — corrected.** It does NOT universally mean "raw source bytes on
   disk," an earlier version's claim. It means the bytes of whichever file
   `import.meta.resolve('@corbagames/engine/games/<id>.js')` resolves to at registration — the
   TypeScript **source** in a repository checkout, the **compiled** `dist` output inside the deployed
   runtime image, per that specifier's own `package.json` `exports` map in each environment. See
   "Step 2" above for the correction in full and the measurement it is based on.
3. **`configUrl` serves the literal canonical JSON text**, not a re-serialized copy — a generic JSON
   encoder is not guaranteed to preserve JCS's key ordering. See "How this feeds the public config
   endpoint" above.
4. **The module bytes themselves are now served** (`GET /public/games/:gameId/:version/module`, U23)
   — closing the gap "How this feeds the public config endpoint" and "Reachable with curl" above used
   to describe as open. See "Superseded by U23" above for exactly what this does and does not close.
