> **Copy, for the deployed site.** The original is `game-master/docs/PROTOCOL.md` and it wins. This copy exists
> so `the-clearing/web/` can be served on its own, because a link to `../../../game-master/docs/PROTOCOL.md` cannot resolve
> on a static host. 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`, `status`, `enrolledAt`, and `holdings`.
- Inventory: the caller's `playerId` and `holdings`.
- Statement: the caller's `playerId` and private movement `entries`.
- Transfer: `transferId`, `toPlayerId`, `assetId`, and `quantity`.
- Exchange: public party Player IDs, immutable Terms, Terms Hash, status/version, timestamps, authenticated Approvals, and `gameRuleFired`.
- Exchange List: `exchanges` and an opaque nullable `nextCursor`.
- 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 caller-facing causality.

## 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`, status, enrolment time, and current Holdings without issuing, reactivating, or bumping state.

## Queries

```json
{"protocolVersion":2,"action":"readInventory"}
{"protocolVersion":2,"action":"readStatement","limit":50}
{"protocolVersion":2,"action":"getExchange","exchangeId":"..."}
{"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. `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 only; 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.

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 an active Player. A Transfer moves once inside one database transaction and writes one Ledger Entry.

## 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.

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

Only the Counterparty may respond. 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 Proposer may cancel:

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

## Game Rules and the fired flag

A deployment may define hidden Game Rules, each active for a window. 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`.

An Exchange result carries `gameRuleFired`. It is `true` only when at least one rule moved the asking party's own goods in that Settlement. It never names a rule, never explains one, and is not part of Terms, so it does not change the Terms Hash. 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 flag 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.

### `unfunded` has two causes, and they are not distinguishable

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

1. the Counterparty does not have enough Available for the Requested Assets, 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: distinguishing them would tell a Player that a rule exists, which is the one thing the rule mechanic withholds. Treat `unfunded` as "this Settlement could not be funded", and do not build logic on the cause. 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.

## 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`. Protocol v1 errors concerning signatures, named Game Masters, and challenge refusal no longer exist.

## 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.
