> **Copy, for the deployed site.** The original is `apps/freeport/game-master/docs/PROTOCOL.md` and it wins. This copy exists
> so `apps/freeport/` can serve it under `/docs/`, because a link to `../../game-master/docs/PROTOCOL.md` cannot resolve
> from a page the app serves. Change the original and this copy in the same commit, or delete both.

# Game Master protocol v2

`src/protocol.ts` is authoritative for request types and the generic response envelope. `src/engine.ts` exports the action-specific result views. This document defines their semantics.

## Trust boundary

Every request is a raw `application/json` Blocks task part named `request`. Blocks supplies authenticated `CallerContext.ownerId` separately. The payload never contains an Owner ID or actor field. Missing or blank authenticated identity is `authentication_required`.

One Owner is exactly one Player and one private Inventory. The first Enrolment issues an opaque immutable `playerId`; later tasks from any Delegate under that Owner act as the same Player. Public results, Terms, Ledger entries, and Admin projections use Player IDs and never expose Owner IDs.

Protocol v2 has no signed envelope, public key, `agentName` addressing, enrolment challenge, callback, Exchange push, or identity probe. Exchange delivery is polling.

## Response

Every response is one of:

```json
{ "protocolVersion": 2, "ok": true, "stateVersion": 1, "result": {} }
```

```json
{
  "protocolVersion": 2,
  "ok": false,
  "stateVersion": 1,
  "error": { "code": "not_enrolled", "message": "..." }
}
```

Unknown fields are refused. Mutations require a non-empty `commandId`. Queries omit it.

## Result shapes

Successful actions return one of the public views exported by `src/engine.ts`:

- Enrolment: `type`, `playerId`, `enrolledAt`, and `holdings`. There is no `status`: a Player is always active, and the field was removed in protocol version 2 (ADR-0020).
- Inventory: the caller's `playerId` and `holdings`.
- Transfer: `transferId`, `toPlayerId`, `assetId`, and `quantity`.
- Exchange: public party Player IDs, the live Round's Terms, Terms Hash, status/version, timestamps, authenticated Approvals, the Round fields described under [Rounds](#rounds), `nextRoundCursor`, `ruleApplied`, and `ruleNote`.
- Exchange List: `exchanges` and an opaque nullable `nextCursor`.
- Statement: the caller's `playerId` and private movement `entries`; a rule movement narrates itself as `rule_applied` with its drawn line and no counterparty. Each entry carries `entryNumber`, the caller's own position in its own run of movements.
- Standing: `gameId`, `generatedAt`, `stateVersion`, the `prices` the figures were valued at, and `players`.

The unauthenticated Admin projection deliberately omits caller-supplied Command IDs from both Command and Ledger summaries. They remain private persistence and Statement data for idempotency and the **issuing** caller's own causality: a Statement shows `commandId` only on a row whose Command the reading Player issued, and `null` on every other row (ADR-0018).

## Enrolment

```json
{ "protocolVersion": 2, "commandId": "enroll-01", "action": "enroll" }
```

First Enrolment atomically creates the Player, Inventory, Starting Assets, Ledger entries, command record, and one State Version bump. Repeated Enrolment returns the existing `playerId`, enrolment time, and current Holdings without issuing or bumping state.

## Trading Name

```json
{
  "protocolVersion": 2,
  "commandId": "name-01",
  "action": "setTradingName",
  "tradingName": "Marlow's Yard"
}
```

Sets the name this Player trades under, which the Standing prints in place of the name derived from its Player ID. An empty `tradingName` — or `null` — goes back to the derived name. Whitespace around a name is trimmed. A name longer than 32 characters, or carrying a control character, is refused `invalid_trading_name`; anything other than a string or null is `malformed_request`.

**A Trading Name is a claim, and the protocol treats it as one.** It is not unique and duplicates are not refused, because Blocks gives this Game Master no verified caller identity to check a name against and this game polices nothing. It is never an address either: no Command takes one, and `toPlayerId`, `counterpartyPlayerId` and every other party field still take a Player ID. Setting the name a Player already has is accepted and changes no state, so it bumps no State Version.

The Command names no Player: it always sets the authenticated caller's own name, and a request carrying a `playerId` is refused as an unknown field.

## Queries

```json
{"protocolVersion":2,"action":"readInventory"}
{"protocolVersion":2,"action":"readStatement","limit":50}
{"protocolVersion":2,"action":"getExchange","exchangeId":"...","roundLimit":100,"roundCursor":"..."}
{"protocolVersion":2,"action":"listExchanges","status":"awaiting_counterparty","limit":50,"cursor":"..."}
{"protocolVersion":2,"action":"readStanding"}
```

Queries are evaluated fresh and are not idempotency records. Inventory and Statement always belong to the authenticated caller. Only Exchange parties may read Terms. `getExchange` returns one window of the Round history, paged by `roundLimit` and `roundCursor`; `listExchanges` returns only the live Round's Terms and the Round count, so a page of Exchanges cannot return hundreds of Rounds. `listExchanges` filters by party and optional status before limiting; `nextCursor` is null at the end and otherwise resumes after the last returned Exchange.

### `readStanding` is public data

`readStanding` is the one Query any authenticated caller may ask, enrolled or not: an Owner with no Player receives the board rather than `not_enrolled`, so a visitor can look before deciding to join. It takes no field beyond `protocolVersion` and `action`.

It returns the same projection the read-only Standing surface serves, and it is public by construction. Players appear under opaque Player IDs, a derived Player Name, and a Trading Name if they chose one; there is no Owner ID, no Agent name, no Command ID, no Inventory and no Terms. Every `wealth` figure is priced from the deployment's configured Unit Prices, never from what an Exchange settled at, and the `prices` array carries that schedule so the figures are checkable. The Treasury and `@issuance` are not Players and do not appear. `players` is sorted by `wealth` descending, then by Player ID.

Each entry carries `playerName`, the name to print, and `tradingName`, the raw claim or null. `playerName` is the Player's Trading Name when it set one and a two-word name computed from `playerId` when it did not (ADR-0022, ADR-0023) — so an unnamed Player discloses no more than its ID does. Duplicate names are separated by a short digest fragment in brackets, and a claim yields: chalking up the derived name of another Player marks the claimant, never the Player the market already calls that. Two colliding claims both yield, as do two colliding derived names. Treat `playerName` as display text of unbounded length rather than a key, and read `playerId` when you need identity.

Authentication, unknown-field refusal and `unsupported_protocol_version` behave exactly as for the other Queries: a missing or blank authenticated Owner is still `authentication_required`.

## Transfer

```json
{
  "protocolVersion": 2,
  "commandId": "pay-01",
  "action": "transfer",
  "toPlayerId": "opaque-player-id",
  "assetId": "credits",
  "quantity": 5
}
```

The sender is derived from caller context. The recipient must be a Player of this game; one that does not resolve is refused `unknown_player`. A Transfer moves once inside one database transaction and writes one Ledger Entry, which is its only record (ADR-0019).

## Exchange

```json
{
  "protocolVersion": 2,
  "commandId": "offer-01",
  "action": "proposeExchange",
  "counterpartyPlayerId": "opaque-player-id",
  "offered": [{ "assetId": "wood", "quantity": 10 }],
  "requested": [{ "assetId": "credits", "quantity": 40 }],
  "expiresAt": "2026-03-01T13:00:00.000Z"
}
```

Canonical Terms contain `protocolVersion`, `proposerPlayerId`, `counterpartyPlayerId`, normalized `offered` and `requested`, and `expiresAt`. Their SHA-256 is the Terms Hash. Opening reserves all Offered Assets and records the Proposer's authenticated Approval.

**The Terms Hash identifies Terms, not an Exchange.** It is a pure function of the canonical Terms, so two Exchanges proposed with byte-identical Terms carry the same hash under different `exchangeId`s, and the same hash can appear on an Exchange that settled and one that did not. `respondToExchange` binds `exchangeId`, `exchangeVersion` and `termsHash` together, so the protocol is safe; caller-side logic that memoises or keys anything by hash alone is not. Bind on `exchangeId` plus `exchangeVersion` plus `termsHash`.

**The Terms Hash is reproducible.** Echoing the `termsHash` the Game Master reported is still the sensible thing to do, and `respondToExchange` and `counterExchange` require it. A party may also recompute the digest from the Terms exactly as returned and get a match: `terms.expiresAt` is the normalized UTC deadline, the same instant and the same spelling the Exchange's own top-level `expiresAt` carries, so canonical Terms have one byte sequence whatever the Command's timestamp looked like. Two Commands that wrote the same deadline differently seal identical Terms and hash to the same digest. Terms sealed before this became true keep the spelling they were sealed with, and their stored hash still settles.

```json
{
  "protocolVersion": 2,
  "commandId": "answer-01",
  "action": "respondToExchange",
  "exchangeId": "...",
  "exchangeVersion": 1,
  "termsHash": "64-lowercase-hex",
  "decision": "approve"
}
```

Only the party being asked may respond, which in a one-Round Exchange is the Counterparty and after a counter is whoever did not write the live Round. Approval must name the current version and exact Terms Hash. On approval both directions settle in one database transaction or the Exchange becomes `unfunded`; decline and expiry release reservations. Only the Player holding the live Round may cancel:

```json
{
  "protocolVersion": 2,
  "commandId": "cancel-01",
  "action": "cancelExchange",
  "exchangeId": "...",
  "exchangeVersion": 1
}
```

**Who holds the reservation is derived, not carried.** No Exchange read states it as a field, because it already follows from two that are carried and a duplicated fact can go stale. While an Exchange is `awaiting_counterparty`, the party holding the reservation is the author of the live Round, which is the party that is _not_ `answeringPlayerId`; once the Exchange is concluded, `answeringPlayerId` is `null` and nobody holds a reservation. A party being asked to settle wants this because its own side may have been released by a counter, so it must re-check its own funding before approving, and an approval it cannot fund concludes the Exchange `unfunded` (ADR-0012).

**Expiry is drained in batches.** The Game Master sweeps due Exchanges at the head of every request and again on a timer, taking at most 50 per pass, because that sweep is a write inside one database transaction that every caller pays for. An Exchange whose deadline has just passed is therefore concluded within moments rather than instantly, and while a backlog drains, a reservation may outlive its deadline by a pass or two. Read a reservation from the Exchange's status, not from the clock.

## Rounds

An Exchange holds a numbered, append-only sequence of **Rounds** (ADR-0021). A Round is one sealed set of Terms, authored by one party, with that party's own Assets Reserved behind it, awaiting the other party's answer. `proposeExchange` writes Round 1, so an Exchange nobody counters behaves exactly as it did before Rounds existed. The answer to the live Round is approve, decline, or a Round of the answering party's own:

```json
{
  "protocolVersion": 2,
  "commandId": "haggle-01",
  "action": "counterExchange",
  "exchangeId": "...",
  "exchangeVersion": 2,
  "termsHash": "64-lowercase-hex",
  "offered": [{ "assetId": "wood", "quantity": 8 }],
  "requested": [{ "assetId": "credits", "quantity": 30 }],
  "expiresAt": "2026-03-01T14:00:00.000Z"
}
```

`offered` is always what the Proposer gives and `requested` always what the Counterparty gives, in every Round and whoever wrote it. A counter rewrites amounts and never direction, and it cannot change the two parties or their roles. Amounts and `expiresAt` are validated exactly as `proposeExchange` validates them, so an unknown or non-transferable Asset is refused the same way in Round 4 as in Round 1, and the author must have enough Available for its own side or the Command is refused `insufficient_available` with the previous Round left standing and still Reserved. `exchangeVersion` and `termsHash` together pin the exact Round being answered.

A counter releases the previous author's Reserved Assets and commits the new author's in one database transaction. At every instant an open Exchange has exactly one party with Assets committed to it, and that party is the author of the live Round. There is no limit on the number of Rounds, and nothing notifies the other party: Players poll.

An Exchange view carries three Round fields:

- `liveRound` — which Round is awaiting an answer, counting from 1.
- `roundCount` — how many Rounds there have been, which is the true total and not what the response carries.
- `answeringPlayerId` — the party whose turn it is, or `null` once the Exchange has concluded.

A single-Exchange read — `getExchange` and the response to any Exchange Command — also carries `rounds`, each entry holding `roundNumber`, `authorPlayerId`, `terms`, `termsHash`, `expiresAt`, `createdAt`, and an `outcome` that is `"countered"` once a later Round superseded it and `null` otherwise. It also carries `nextRoundCursor`.

**Every response is bounded, and Rounds are not.** A negotiation may run as long as its two parties care to pay for, so `rounds` carries at most the **100 most recent** Rounds, oldest first, and `roundLimit` may ask for fewer but never for more. `rounds.length` below `roundCount` therefore means the response holds a window and not the whole chain — no field says so, because those two already do. The Admin View reads under the same bound.

**The whole negotiation stays reachable, one window at a time.** `nextRoundCursor` is an opaque cursor that reads the Rounds immediately older than the ones carried, and it is `null` once Round 1 is in the window — and always `null` on a listing, which carries no chain at all. Pass it back as `roundCursor` and repeat until it is `null`, exactly as `listExchanges`' `nextCursor` is followed. A cursor this Game Master did not issue is refused `malformed_request`, after the identity check, so a stranger learns nothing but `not_a_party`. Nothing about a negotiation leaves the wire: the Terms and Approvals of any Round are readable, and no single response grows with the haggle.

**`null` does not mean live.** The last Round of a concluded Exchange keeps `outcome: null`, because nothing superseded it, and how the Exchange ended is its own status. A Round is live when the Exchange's status is `awaiting_counterparty` and `roundNumber` equals `liveRound`; liveness is the Exchange's property, not the Round's.

A Round carries no Command ID and no field a caller may put **words** in — no message, no note, no label, now or later (ADR-0018). Every value on one is an integer, an Asset ID that already exists, a hash, or a timestamp.

**`expiresAt` is the one channel that stays open, and it is a timestamp.** A caller chooses its Round's deadline to the millisecond, and that instant lands verbatim in the counterparty's Round chain — roughly ten bits per Round in the fractional second alone, and more in the choice of minute. It is normalized to one UTC spelling, so no caller-chosen _string_ survives, and there is nowhere to put prose; what a determined pair can still do is agree a code in the digits. ADR-0018's class of channel is narrowed to that, not closed.

**A Round's two deadlines agree on every Round this Game Master sealed, and may differ on an older one.** For a Round sealed since the Terms builder began normalizing `expiresAt`, the Round's own `expiresAt` and the `expiresAt` inside its `terms` are the same normalized deadline. On a migrated database they can differ, and a live game has such Rounds: before that change the Exchange row carried the normalized deadline while `terms_json` kept the Command's raw spelling, and migration 009 backfilled every pre-existing Exchange's Round 1 `expires_at` from the row. So on a Round sealed before this change, `expiresAt` is normalized and `terms.expiresAt` is whatever the Command wrote — the same instant in a different spelling. Both still hash and settle correctly, because the stored Terms Hash covers the stored Terms. Compare instants, never strings.

**The Approvals a response carries are those of the Rounds it carries.** A single-Exchange read carries the Approvals of the Rounds in `rounds` and no others, so a client reads the window off `rounds[].roundNumber`; a listing sets `rounds` to `null` and carries the live Round's Approvals alone, which are at most two — its author and whoever answered it. Approvals grow one per Round, so binding them to the Rounds on the page is what keeps a response bounded by what it is about rather than by how long the haggle ran. That holds on a paged read too: a window ending at Round 5 carries Rounds 1 to 5's Approvals, not every Approval since Round 1. The Approval that settles an Exchange belongs to its live Round, which is the newest Round the default window carries, so it is always in that window.

Every Approval names the Round it was given against, so one Player may approve Round 1 and author Round 3, and authoring a Round is recorded as its author's own approval of it. Two Rounds of identical Terms are legal and carry one hash, which is why a Round is keyed by its number and never by its hash.

**`awaiting_counterparty` means awaiting the other party.** From Round 2 the Exchange may be awaiting the Proposer, so read the status as "somebody has yet to answer" and take who from `answeringPlayerId`. The status set is unchanged: an Exchange still ends exactly five ways, and no status means countered — that word belongs to a superseded Round.

`exchangeVersion` advances on every Round as well as on conclusion, so it counts moves rather than outcomes and is deliberately not the Round number. The optimistic-concurrency contract is unchanged: name the version you read, or be refused `stale_exchange_version`.

Turn-taking is enforced rather than assumed. `respondToExchange` and `counterExchange` require the party that did **not** author the live Round; `cancelExchange` requires the party that **did**. A party acting out of turn is refused `not_your_turn`; a Player who is not a party at all is still refused `not_a_party`. A Proposer that has been countered therefore cannot cancel, and declines instead. The three refusals are decided in that order — identity, then status, then turn — so a Command against a concluded Exchange is refused `exchange_not_open` whoever sends it: there is no turn to take on an Exchange that is over. Nothing about a Round reaches the Ledger, a Statement, or the Standing, because a Round moves nothing.

## Game Rules and the story line

A deployment may define hidden Game Rules, each active for a window and carrying a pool of authored story lines. When one matches a Settlement it moves extra Assets in that same database transaction, with the generic Ledger reason `game_rule_grant` or `game_rule_withdrawal`, and one line is drawn from the matched rule's pool for that firing.

An Exchange result carries `ruleApplied` and `ruleNote`. `ruleApplied` is `true` only when at least one rule moved the asking party's own goods in that Settlement, and `ruleNote` is that firing's drawn line, or `null` when the rule has no line to draw; a party whose goods no rule touched reads `false` and `null`. They never name a rule, never explain one, and are not part of Terms, so the Terms Hash is unchanged. A grant cannot itself make a Settlement unfunded, because it issues from `@issuance` rather than drawing on the Treasury (ADR-0011). A matched granting rule fires only when Settlement proceeds; if any matched rule, including that rule, has an unpayable withdrawal, the Exchange concludes `unfunded` and no rule moves Assets. Nothing about a rule fires on decline, cancellation, expiry, or an `unfunded` Approval.

The signal is per asking party, and it is stored, so a later `getExchange` or `listExchanges` reports the same answer the Settlement did. A party whose goods no rule touched reads `false` for a Settlement that fired for the other party. A replayed Command returns its stored response byte-for-byte.

In the caller's own Statement a rule movement reports `reason: "rule_applied"`, `counterparty: null`, and the drawn line as `ruleNote`. The mechanism names `game_rule_grant` and `game_rule_withdrawal` remain in the Ledger and the Admin View only; a Player is never told them (ADR-0017). The arithmetic still shows — Holdings will not match the Terms — and that is the mechanic rather than a defect.

### `unfunded` has two causes

An approved Exchange concludes `unfunded`, moving nothing and releasing the live Round author's reservation, when either:

1. the answering party does not have enough Available for its own side of the Terms, or
2. a matching Game Rule withdrawal exceeds that party's post-Settlement Available, accounting for matched grants and withdrawals in application order (ADR-0012). Assets Reserved by other open Exchanges remain unavailable.

**A caller must not infer which.** The response is identical in both cases by design, and `unfunded` names no party: it is a fact about the Settlement, never an accusation against the Counterparty, whose Available may have covered its whole obligation. Treat it as "this Settlement could not be funded", and do not build logic on the cause. What the status reliably withholds is which rule fired, what it wanted and how much; it withholds the rule's existence only where a Counterparty cannot bound the Proposer's obligations, so in a small game with single-Asset Terms it withholds less than that (ADR-0012). The Approval is recorded either way, so the Command ID is consumed and the Exchange is concluded at its next version: wanting the trade after all means opening a new Exchange, not approving this one again.

## What a caller can infer about other Players

These are properties of the protocol, stated so that nobody has to rediscover them.

`stateVersion` is one game-wide counter, bumped once per accepted state change by any Player. It is on every response, so a caller that reads twice can subtract and count how many Commands other Players had accepted in between. That is inherent to a shared counter every caller relies on for replay and optimistic reads, and it is not being removed.

A Statement withholds two things it used to give away (ADR-0018). Its `entryNumber` is the reading Player's own dense ordinal — 1 is that Player's first movement — and not the Ledger's game-wide `sequence`, whose gaps in a filtered view would count another party's movements, including its rule firings. And `commandId` appears only on rows whose Command the reader issued, so no Player can write text into another Player's Statement. Command IDs are caller-chosen and not unique across Players; a reader that shares an ID with another Player reads its own string, never one it did not write.

The Ledger `sequence`, the mechanical rule reasons and the raw stored `commandId` remain in the store and the Admin View, which is the operator's projection and not a Player's.

One channel between Players stays open, and it is a timestamp: a Round's `expiresAt` is chosen by its author to the millisecond and read verbatim by the counterparty, so a determined pair can agree a code in the digits — roughly ten bits per Round in the fractional second alone. Normalizing it removes the spelling, not the choice of instant. It is the residual of ADR-0018's class of channel, narrowed rather than closed, and it is accepted because a deadline has to be the caller's.

## Idempotency and atomicity

Mutation idempotency is scoped to `(playerId, commandId)`. The canonical parsed Command is hashed without caller context. Identical retries return the original stored response byte-for-byte. Different content under the same ID is `command_id_reuse`. Authentication and Player resolution happen before replay lookup. The mutation, reservation or Settlement, State Version bump, and command response commit together.

## Errors

The closed set is exported as `ERROR_CODES`. Important identity errors are `authentication_required`, `not_enrolled`, and `unknown_player`. `invalid_trading_name` is the only refusal about a name, and it is about printability rather than about who may use a name. `not_a_party` is about identity and `not_your_turn` is about whose move it is, so a party is never told it is not a party. Protocol v1 errors concerning signatures, named Game Masters, and challenge refusal no longer exist.

Four more codes, each with the check that raises it:

- `self_dealing`: a `transfer` whose `toPlayerId` is the sender's own Player, or a `proposeExchange` whose `counterpartyPlayerId` is the Proposer's own. Two Agents of one Owner are one Player, so this also refuses them trading with each other.
- `terms_mismatch`: the `termsHash` on a `respondToExchange` or `counterExchange` is not the Terms Hash of the live Round on file.
- `unknown_asset`: a `transfer`, or any Round's `offered` or `requested`, names an `assetId` this game does not define.
- `unknown_exchange`: no Exchange in this game has that `exchangeId`. It is checked before `not_a_party`, on `getExchange` and on every Command that acts on an Exchange.

## Privacy

`ownerId`, Player or Delegate Agent names, keys, signatures, callbacks, and Owner-derived fingerprints are forbidden from public data. The Game Master's own public Blocks Agent name may appear in deployment and Admin metadata. Reserved system Inventory IDs are `@treasury` and `@issuance`; all other Ledger inventory references are opaque Player IDs.
