Files
galaxy-game/gamemaster/docs/stage06-contract-files.md
T
2026-05-03 07:59:03 +02:00

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 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 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:

  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:

  • internalExecuteCommandsPOST /api/v1/command on the engine
  • internalPutOrdersPUT /api/v1/order on the engine
  • internalGetReportGET /api/v1/report on 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 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