--- stage: 06 title: Contract files and contract tests --- # Stage 06 — Contract files and contract tests This decision record captures the non-obvious choices made while producing the machine-readable contracts for `Game Master`: [`../api/internal-openapi.yaml`](../api/internal-openapi.yaml), [`../api/runtime-events-asyncapi.yaml`](../api/runtime-events-asyncapi.yaml), and the matching contract tests in the `gamemaster` package. ## Context [`../PLAN.md` Stage 06](../PLAN.md) freezes the GM REST and event contracts before any handler is written, so later stages have a target spec. The plan enumerates the 20 internal REST `operationId` values and the two `gm:lobby_events` message types and asks contract tests to fail loudly if anything drifts. Three decisions were not derivable from `../README.md` or [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) and required a deliberate choice while writing the YAML. ## Decision 1 — Two messages and two send operations on one channel `gm:lobby_events` carries two distinct message types — a recurring `runtime_snapshot_update` and a terminal `game_finished`. The AsyncAPI 3.1.0 surface encodes them as **two separate messages on one channel with one `send` operation per message**: ```yaml channels: lobbyEvents: address: gm:lobby_events messages: runtimeSnapshotUpdate: { $ref: '#/components/messages/RuntimeSnapshotUpdate' } gameFinished: { $ref: '#/components/messages/GameFinished' } operations: publishRuntimeSnapshotUpdate: { action: send, ... } publishGameFinished: { action: send, ... } ``` The `notification:intents` contract uses a single message with `allOf`-conditional discriminator branches; the `runtime:health_events` contract uses a single message with a `oneOf` `details` field. Both patterns work when most fields are shared and only one variant slot differs. For `gm:lobby_events` the two payloads share only `event_type`, `game_id`, `runtime_status`, and `player_turn_stats[]`. The remaining fields (`current_turn`, `engine_health_summary`, `occurred_at_ms` on the snapshot vs `final_turn_number`, `finished_at_ms` on the finish event) have no overlap, and their semantics differ — the snapshot is recurring, the finish event is terminal. Two messages reflect this asymmetry directly and keep each payload schema closed without needing per-variant `if/then` rules. Alternatives considered: - **One message with `allOf` discriminator** — rejected: would force every shared field to be optional at the envelope level and re-required inside each `if/then` branch, doubling the schema size and complicating the contract test. The notification spec accepts this cost because it has 18 message types and the payload-shape asymmetry is the whole point; here it's two types with no field overlap. - **Two channels** — rejected: would require Game Lobby to subscribe to two streams, breaking the cadence guarantees in `../README.md` §Async Stream Contracts ("snapshot transitions and finish are ordered relative to each other on the same stream"). ## Decision 2 — `event_type` is a required schema-level `const` [`../PLAN.md` Stage 06](../PLAN.md) lists the "frozen field set per message" without naming `event_type`. The implementation pins `event_type` as a required schema property with a `const` value: ```yaml RuntimeSnapshotUpdatePayload: required: [event_type, ...] properties: event_type: { type: string, const: runtime_snapshot_update } ``` Reasons: 1. The wire payload must carry a discriminator; consumers (Game Lobby) dispatch on `event_type` after `XREAD`. Omitting it from the schema would require Game Master to inject the value at publish time without spec backing. 2. `const` at the schema level lets the contract test assert the discriminator value, which is the only meaningful check Stage 06 asks for ("`event_type` discriminator values"). Asserting only the message component name without the on-wire `event_type` would not protect consumers from a misconfigured publisher. 3. `rtmanager/api/runtime-health-asyncapi.yaml` already uses `event_type` as a schema-level enum-typed discriminator; treating `gm:lobby_events` the same way keeps the patterns consistent for a reader cross-walking the two specs. Alternatives considered: - **Leave `event_type` out of the spec and produce it only at the publish-side adapter** — rejected: hides the discriminator from the contract test, which then cannot fail when the publisher renames or drops it. - **Encode discrimination through AsyncAPI message names alone** (relying on `header.X-Message-Type` or similar) — rejected: Redis Streams have no message-headers concept; everything travels in the payload field set. ## Decision 3 — `additionalProperties: true` on engine pass-through schemas Three internal REST operations forward engine-owned payloads without modification: - `internalExecuteCommands` — `POST /api/v1/command` on the engine - `internalPutOrders` — `PUT /api/v1/order` on the engine - `internalGetReport` — `GET /api/v1/report` on the engine Their request and response bodies use `additionalProperties: true`: ```yaml ExecuteCommandsRequest: type: object additionalProperties: true required: [commands] properties: commands: type: array items: { type: object, additionalProperties: true } ``` Game Master does not own the shape of these payloads — `galaxy/game/openapi.yaml` is the source of truth — and freezing them in the GM contract would turn every engine-side schema bump into a coordinated GM release. The same reasoning applies to `EngineVersion.options`, which is a free-form `jsonb` document Game Master stores verbatim. To prevent the open-by-default flag from spreading by accident, the contract test [`../contract_openapi_test.go`](../contract_openapi_test.go) maintains two explicit allowlists: - `gmOwnedClosedSchemas` — every schema for which Game Master owns the wire shape; the test asserts each one closes with `additionalProperties: false`. - `engineOwnedPassthroughSchemas` — the five pass-through schemas (request and response bodies of the three hot-path operations); the test asserts each one keeps `additionalProperties: true`. Adding a new GM schema requires registering it in `gmOwnedClosedSchemas`; the test fails loudly if it isn't. Alternatives considered: - **Close the pass-through schemas with `additionalProperties: false` and hand-mirror every engine field** — rejected: `galaxy/game` and `galaxy/gamemaster` would have to release in lockstep; even cosmetic field renames in the engine would break Edge Gateway routing. - **Rely on a `// pass-through` comment in the YAML alone** — rejected: comments do not survive automated reformatters and provide no test-time signal. ## References - [`../PLAN.md` Stage 06](../PLAN.md) - [`../README.md` §Hot Path](../README.md), [`../README.md` §Async Stream Contracts](../README.md) - [`../api/internal-openapi.yaml`](../api/internal-openapi.yaml) - [`../api/runtime-events-asyncapi.yaml`](../api/runtime-events-asyncapi.yaml) - [`../contract_openapi_test.go`](../contract_openapi_test.go) - [`../contract_asyncapi_test.go`](../contract_asyncapi_test.go) - [`../../lobby/contract_openapi_test.go`](../../lobby/contract_openapi_test.go) — OpenAPI test pattern reused here. - [`../../notification/contract_asyncapi_test.go`](../../notification/contract_asyncapi_test.go) — YAML walker pattern reused here. - [`../../rtmanager/api/runtime-health-asyncapi.yaml`](../../rtmanager/api/runtime-health-asyncapi.yaml) — `event_type` const precedent.