7.5 KiB
stage, title
| stage | title |
|---|---|
| 06 | 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/runtime-events-asyncapi.yaml,
and the matching contract tests in the gamemaster package.
Context
../PLAN.md Stage 06 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 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:
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
allOfdiscriminator — rejected: would force every shared field to be optional at the envelope level and re-required inside eachif/thenbranch, 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 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:
RuntimeSnapshotUpdatePayload:
required: [event_type, ...]
properties:
event_type: { type: string, const: runtime_snapshot_update }
Reasons:
- The wire payload must carry a discriminator; consumers (Game Lobby)
dispatch on
event_typeafterXREAD. Omitting it from the schema would require Game Master to inject the value at publish time without spec backing. constat the schema level lets the contract test assert the discriminator value, which is the only meaningful check Stage 06 asks for ("event_typediscriminator values"). Asserting only the message component name without the on-wireevent_typewould not protect consumers from a misconfigured publisher.rtmanager/api/runtime-health-asyncapi.yamlalready usesevent_typeas a schema-level enum-typed discriminator; treatinggm:lobby_eventsthe same way keeps the patterns consistent for a reader cross-walking the two specs.
Alternatives considered:
- Leave
event_typeout 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-Typeor 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/commandon the engineinternalPutOrders—PUT /api/v1/orderon the engineinternalGetReport—GET /api/v1/reporton the engine
Their request and response bodies use additionalProperties: true:
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 maintains
two explicit allowlists:
gmOwnedClosedSchemas— every schema for which Game Master owns the wire shape; the test asserts each one closes withadditionalProperties: false.engineOwnedPassthroughSchemas— the five pass-through schemas (request and response bodies of the three hot-path operations); the test asserts each one keepsadditionalProperties: 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: falseand hand-mirror every engine field — rejected:galaxy/gameandgalaxy/gamemasterwould have to release in lockstep; even cosmetic field renames in the engine would break Edge Gateway routing. - Rely on a
// pass-throughcomment in the YAML alone — rejected: comments do not survive automated reformatters and provide no test-time signal.
References
../PLAN.mdStage 06../README.md§Hot Path,../README.md§Async Stream Contracts../api/internal-openapi.yaml../api/runtime-events-asyncapi.yaml../contract_openapi_test.go../contract_asyncapi_test.go../../lobby/contract_openapi_test.go— OpenAPI test pattern reused here.../../notification/contract_asyncapi_test.go— YAML walker pattern reused here.../../rtmanager/api/runtime-health-asyncapi.yaml—event_typeconst precedent.