Hold & Win — backend response contract

The implemented backend is authoritative, not this document. Every JSON block below is a byte-for-byte copy of a fixture captured from the live spin orchestrator — the same code path a player's request takes — and a drift guard fails the build if the runtime stops producing it. A hand-written example that disagrees with handler output is worse than no example, because it reads as a contract while being fiction.

The distinction between a live fixture and a mapper fixture is the point. A mapper fixture proves the projection function is stable and says nothing about whether the runtime ever calls it. These were produced by driving SpinOrchestrator.Spin against real PostgreSQL and serializing what came back.

FieldValue
Canonical contractapps/game-engine/internal/domain/round/hold_and_win_response.go
Runtime that emits itapps/game-engine/internal/app/service/spin_orchestrator_hold_and_win.go, …_paid_writers.go
Schemaapps/game-engine/api/openapi.yamlHoldAndWinNextState, HoldAndWinCell, HoldAndWinPrize, HoldAndWinSettlement
Live fixturesapps/game-engine/internal/app/service/testdata/hold_and_win/*.json
Fixture capture + drift guardTestHAWLiveResponseFixtures (service, integration tag)
Wire guardTestHoldAndWinNextStateReachesTheWireUnchanged (handlers)
OpenAPI guardTestOpenAPIHoldAndWinExampleMatchesTheLiveFixture (handlers)
Backend Git SHAcd4c093 on feat/hold-and-win-reference
Math revisiondraft-v0.2, config SHA-256 a22129d99d21eb14acdb303efdf666f4065925fa461012d8e99f2144538d0508
Mechanic versionhold_and_win/v1
Statusresponse contract implemented, routed live, pinned and documented

How these fixtures were produced

Determinism comes from a pinned seed. debug.force_seed fixes the trigger round's RNG namespace, and every mechanic sub-seed derives from it — <roundSeed>:hold_and_win:start and <roundSeed>:hold_and_win:step:<n> — so the whole feature is reproducible.

The one value that cannot be pinned is the round id, which is minted per operation. It is normalised to an all-zero UUID, and because next_step_id is derived from it the same substitution keeps that field honest rather than hiding it. Every other value below is exactly what the runtime produced.

cd /Users/admin/kiro/backend

# Capture / verify the live fixtures (needs PostgreSQL).
TEST_DATABASE_URL='postgres://game@127.0.0.1:55432/game_engine?sslmode=disable' \
  go test -tags integration ./apps/game-engine/internal/app/service \
  -run TestHAWLiveResponseFixtures -count=1

# Regenerate them deliberately after an intended contract change.
UPDATE_HAW_FIXTURES=1 TEST_DATABASE_URL='…' go test -tags integration -run TestHAWLiveResponseFixtures

# The wire and schema guards (no database needed).
go test ./apps/game-engine/internal/app/handlers -run 'HoldAndWin|OpenAPI' -count=1

The fixture game is a 3×5 pack whose two leftmost reels carry only BONUS, so the trigger grid is fully determined at 10 locked cells. LandingChancePPM is the only parameter varied between fixtures, because it is the one knob that steers the feature to a chosen terminal state:

FixtureLanding chanceWhat it shows
trigger43,000 PPM (draft-v0.2)the feature as it opens
respin43,000 PPMa landing, and the respin reset it causes
reconnect43,000 PPMresume after the Redis cache was flushed
settlement-no-respins0 PPMthree misses, ordinary sum settlement
settlement-full-grid1,000,000 PPMgrid filled in one step, Grand

Design rules this contract follows

  1. Every response carries the complete grid. All 15 cells, always, in normative order — not only the per-step delta. A client that dropped a response or reconnected mid-feature must be able to reconstruct the 3×5 grid from the response alone, with no local outcome inference. Sending only the delta would be smaller and would break exactly the cases that matter.
  2. The delta is sent as well, in newly_landed, because a reveal animation needs to know which cells just lit up. It is always an array, never null, so a client can iterate without a presence check.
  3. Each cell sends index, column and row. Any one determines the others, but sending all three means a client never has to know the mechanic's index convention to place a cell. index = column × rows + row.
  4. prize is nullable. A locked cell always carries one; an empty cell never does. A zero-valued prize object would make "no prize" indistinguishable from "a prize worth nothing".
  5. Server-owned identities never appear. No durable sequence id, logical session id, config hash, RNG algorithm, state schema version or revision. Asserted absent against the live response by TestHAWOrch_ResponseCarriesNoServerIdentity.
  6. next_step_id is derived by the server and handed to the client. The mechanic accepts only that value or the last executed one, so a stale or invented identity is refused rather than replayed against the wrong state. That is what makes a retry safe without letting a caller name a step it does not own.
  7. accumulated_multiplier is not money owed. The feature pays once, at settlement. Only settlement.award_multiplier is payable.

Request contract

A respin is a POST /game/v1/spin with no additional wager, carrying the echoed step index:

{ "bet": 1.0, "expected_hold_and_win_step_index": 0 }

bet is still required by the endpoint's schema and is not charged while a feature is open — the orchestrator routes the request to the respin path before any wallet call, and the response reports wallet.bet as 0.

expected_hold_and_win_step_index is the client's half of the operation key; the server supplies the other half from its own sequence id, which never leaves the backend. Presence is significant and distinct from the value 0, which is a valid first step: omit the field entirely (or send null) to mean "not supplied".

Client sendsServer doesPublic error
the expected indexexecutes the respin
the previous indexreturns the recorded answer, byte-identical
an index ahead of the serverrefusesHAW_STEP_INDEX_AHEAD (409)
an index older than the previous onerefusesHAW_RECONCILIATION_REQUIRED (409)
nothing, while a feature is openrefusesHAW_STEP_INDEX_REQUIRED (400)
a non-integer or negative valuerefusesHAW_STEP_INDEX_INVALID (400)

The replay window is exactly one step wide, and that is a property of the storage rather than a policy choice: the durable row holds the state after the most recent step, so it can reproduce that step's answer and no earlier one. Returning the current row for an older index would hand a client a grid from a step it did not ask about while calling it a replay.

Other refusals: HAW_FEATURE_QUARANTINED (409) when the durable state could not be trusted, HAW_CONFIG_UNAVAILABLE (409) when the math the feature started on no longer resolves, HAW_NOT_PRODUCTION_ELIGIBLE (503), and HAW_OPERATION_NOT_FOUND (404) — which is also what an ownership mismatch looks like from outside, so a caller cannot tell "that feature belongs to someone else" from "there is no such feature".

Lifecycle

base spin, 6+ BONUS
   └─> TRIGGER response      status=open, step_index=0, newly_landed=[]
          │                  next_step_id = "<parent_round_id>:step:0"
          │                  the durable feature is opened in the SAME transaction

       client echoes expected_step_index

          ├─> RESPIN response    status=open, step_index+=1
          │                      newly_landed = cells this step locked (may be empty)
          │                      respins reset to 3 on a land, decrement on a miss

          ├─> DUPLICATE STEP     the same index sent twice returns the SAME response,
          │                      consumes no RNG and charges no wager

          └─> RECONNECT          after a cache flush the feature is served from PostgreSQL;
                                 nothing is lost and nothing is re-drawn

       respins reach 0  OR  all 15 cells locked
          └─> SETTLEMENT response  status=completed, settlement present, no next_step_id
                                   ONE credit intent written in the settling transaction

A respin charges no wager. The trigger spin is the only wagered round in the whole feature.

Trigger response

Ten cells locked from the trigger grid, three respins, no step taken yet.

Verbatim from testdata/hold_and_win/trigger.json:

{
  "kind": "hold_and_win",
  "mechanic_version": "hold_and_win/v1",
  "status": "open",
  "step_index": 0,
  "expected_step_index": 0,
  "respins_remaining": 3,
  "columns": 3,
  "rows": 5,
  "locked_cells": 10,
  "total_cells": 15,
  "accumulated_multiplier": 40,
  "cells": [
    {
      "index": 0,
      "column": 0,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 20
      }
    },
    {
      "index": 1,
      "column": 0,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 2,
      "column": 0,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 3,
      "column": 0,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 4,
      "column": 0,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 5,
      "column": 1,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 6,
      "column": 1,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 7,
      "column": 1,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 8,
      "column": 1,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 9,
      "column": 1,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 10,
      "column": 2,
      "row": 0,
      "locked": false,
      "prize": null
    },
    {
      "index": 11,
      "column": 2,
      "row": 1,
      "locked": false,
      "prize": null
    },
    {
      "index": 12,
      "column": 2,
      "row": 2,
      "locked": false,
      "prize": null
    },
    {
      "index": 13,
      "column": 2,
      "row": 3,
      "locked": false,
      "prize": null
    },
    {
      "index": 14,
      "column": 2,
      "row": 4,
      "locked": false,
      "prize": null
    }
  ],
  "newly_landed": [],
  "parent_round_id": "00000000-0000-0000-0000-000000000000",
  "next_step_id": "00000000-0000-0000-0000-000000000000:step:0"
}

Ordinary respin response

One cell landed at index 11. newly_landed names it, the snapshot shows it locked, locked_cells moved from 10 to 11, accumulated_multiplier from 40 to 41, and respins_remaining reset to 3 because something landed.

Verbatim from testdata/hold_and_win/respin.json:

{
  "kind": "hold_and_win",
  "mechanic_version": "hold_and_win/v1",
  "status": "open",
  "step_index": 1,
  "expected_step_index": 1,
  "respins_remaining": 3,
  "columns": 3,
  "rows": 5,
  "locked_cells": 11,
  "total_cells": 15,
  "accumulated_multiplier": 41,
  "cells": [
    {
      "index": 0,
      "column": 0,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 20
      }
    },
    {
      "index": 1,
      "column": 0,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 2,
      "column": 0,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 3,
      "column": 0,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 4,
      "column": 0,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 5,
      "column": 1,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 6,
      "column": 1,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 7,
      "column": 1,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 8,
      "column": 1,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 9,
      "column": 1,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 10,
      "column": 2,
      "row": 0,
      "locked": false,
      "prize": null
    },
    {
      "index": 11,
      "column": 2,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 12,
      "column": 2,
      "row": 2,
      "locked": false,
      "prize": null
    },
    {
      "index": 13,
      "column": 2,
      "row": 3,
      "locked": false,
      "prize": null
    },
    {
      "index": 14,
      "column": 2,
      "row": 4,
      "locked": false,
      "prize": null
    }
  ],
  "newly_landed": [
    11
  ],
  "parent_round_id": "00000000-0000-0000-0000-000000000000",
  "next_step_id": "00000000-0000-0000-0000-000000000000:step:1"
}

A miss-only step looks the same except newly_landed is [], locked_cells and accumulated_multiplier are unchanged, and respins_remaining has decreased by exactly one.

Duplicate step (retry)

Sending the same expected_hold_and_win_step_index twice returns the identical response — the bytes above, unchanged. The server consumes no randomness, allocates no round and charges nothing; the durable step-operation key (sequence_id, step_index) is what turns a retry into a replay instead of a second respin.

There is no distinguishing field, by design: a client that behaves differently on a retry is a client that can double-count. TestHAWOrch_DuplicateStepReturnsTheExactPersistedReplay asserts the serialized equality and that the feature did not advance.

Reconnect

The Redis copy is a cache; PostgreSQL is the source of truth. After the cache is flushed the next request is served from the durable row — same shape, same completeness, no wager, no re-draw.

Verbatim from testdata/hold_and_win/reconnect.json, captured after clearing the cache mid-feature:

{
  "kind": "hold_and_win",
  "mechanic_version": "hold_and_win/v1",
  "status": "open",
  "step_index": 2,
  "expected_step_index": 2,
  "respins_remaining": 2,
  "columns": 3,
  "rows": 5,
  "locked_cells": 11,
  "total_cells": 15,
  "accumulated_multiplier": 41,
  "cells": [
    {
      "index": 0,
      "column": 0,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 20
      }
    },
    {
      "index": 1,
      "column": 0,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 2,
      "column": 0,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 3,
      "column": 0,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 4,
      "column": 0,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 5,
      "column": 1,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 6,
      "column": 1,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 7,
      "column": 1,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 8,
      "column": 1,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 9,
      "column": 1,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 10,
      "column": 2,
      "row": 0,
      "locked": false,
      "prize": null
    },
    {
      "index": 11,
      "column": 2,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 12,
      "column": 2,
      "row": 2,
      "locked": false,
      "prize": null
    },
    {
      "index": 13,
      "column": 2,
      "row": 3,
      "locked": false,
      "prize": null
    },
    {
      "index": 14,
      "column": 2,
      "row": 4,
      "locked": false,
      "prize": null
    }
  ],
  "newly_landed": [],
  "parent_round_id": "00000000-0000-0000-0000-000000000000",
  "next_step_id": "00000000-0000-0000-0000-000000000000:step:2"
}

Settlement — respins exhausted

Three miss-only steps at 0 PPM. The award is the sum of the trigger prizes, below the cap.

Verbatim from testdata/hold_and_win/settlement-no-respins.json:

{
  "kind": "hold_and_win",
  "mechanic_version": "hold_and_win/v1",
  "status": "completed",
  "step_index": 3,
  "expected_step_index": 3,
  "respins_remaining": 0,
  "columns": 3,
  "rows": 5,
  "locked_cells": 10,
  "total_cells": 15,
  "accumulated_multiplier": 40,
  "cells": [
    {
      "index": 0,
      "column": 0,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 20
      }
    },
    {
      "index": 1,
      "column": 0,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 2,
      "column": 0,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 3,
      "column": 0,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 4,
      "column": 0,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 5,
      "column": 1,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 6,
      "column": 1,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 7,
      "column": 1,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 8,
      "column": 1,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 9,
      "column": 1,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 10,
      "column": 2,
      "row": 0,
      "locked": false,
      "prize": null
    },
    {
      "index": 11,
      "column": 2,
      "row": 1,
      "locked": false,
      "prize": null
    },
    {
      "index": 12,
      "column": 2,
      "row": 2,
      "locked": false,
      "prize": null
    },
    {
      "index": 13,
      "column": 2,
      "row": 3,
      "locked": false,
      "prize": null
    },
    {
      "index": 14,
      "column": 2,
      "row": 4,
      "locked": false,
      "prize": null
    }
  ],
  "newly_landed": [],
  "parent_round_id": "00000000-0000-0000-0000-000000000000",
  "settlement": {
    "reason": "no_respins_remaining",
    "raw_multiplier": 40,
    "award_multiplier": 40,
    "capped": false,
    "full_grid": false,
    "jackpot_counts": {
      "regular": 10
    }
  }
}

next_step_id is absent: there is no step to take. status is completed, and one credit intent was written in the same transaction that settled the feature.

Settlement — full grid

Every cell locked. The Grand replaces the prize sum, so a raw sum of 54 pays 500.

Verbatim from testdata/hold_and_win/settlement-full-grid.json:

{
  "kind": "hold_and_win",
  "mechanic_version": "hold_and_win/v1",
  "status": "completed",
  "step_index": 1,
  "expected_step_index": 1,
  "respins_remaining": 0,
  "columns": 3,
  "rows": 5,
  "locked_cells": 15,
  "total_cells": 15,
  "accumulated_multiplier": 54,
  "cells": [
    {
      "index": 0,
      "column": 0,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 20
      }
    },
    {
      "index": 1,
      "column": 0,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 2,
      "column": 0,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 3,
      "column": 0,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 4,
      "column": 0,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 5,
      "column": 1,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 6,
      "column": 1,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 7,
      "column": 1,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 8,
      "column": 1,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 9,
      "column": 1,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 10,
      "column": 2,
      "row": 0,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 5
      }
    },
    {
      "index": 11,
      "column": 2,
      "row": 1,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 2
      }
    },
    {
      "index": 12,
      "column": 2,
      "row": 2,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 3
      }
    },
    {
      "index": 13,
      "column": 2,
      "row": 3,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 1
      }
    },
    {
      "index": 14,
      "column": 2,
      "row": 4,
      "locked": true,
      "prize": {
        "kind": "regular",
        "multiplier": 3
      }
    }
  ],
  "newly_landed": [
    10,
    11,
    12,
    13,
    14
  ],
  "parent_round_id": "00000000-0000-0000-0000-000000000000",
  "settlement": {
    "reason": "full_grid",
    "raw_multiplier": 54,
    "award_multiplier": 500,
    "capped": false,
    "full_grid": true,
    "jackpot_counts": {
      "regular": 15
    }
  }
}

capped is false here, and that is deliberate. The Grand replaced the sum rather than clipping it — the award is higher than raw — so reporting this as capped would be backwards. capped is derived from raw > award rather than stored, so it cannot disagree with the two numbers printed beside it.

Settlement — ordinary sum above the cap

Not reachable from the fixture pack (its trigger prizes cannot sum past 500×), so no live fixture exists for it and none is invented here. The shape is the one above with capped: true: raw_multiplier above 500, award_multiplier exactly 500, full_grid: false. The cap itself is covered by the mechanic's own tests in libs/mathengine/hold_and_win_test.go.

Field reference

FieldTypeNotes
kindstringalways "hold_and_win"; the discriminator on next_state
mechanic_versionstringwhich rules produced this state. Durable state carrying an unknown version fails closed
statusenumopen | completed | quarantined
step_indexintsteps executed so far
expected_step_indexintthe step the server will accept next; equals step_index while open. This is the value the client echoes
respins_remainingintresets to 3 on any land, decrements on a miss-only step
columns, rowsint3 and 5 for this pack
locked_cells, total_cellsinttotal_cells is 15; jackpot_counts sums to locked_cells
accumulated_multipliernumberrunning prize sum. Not money owed
cells[]arraycomplete grid, total_cells entries, ascending index
cells[].prizeobject | nullpresent exactly when locked is true
newly_landed[]arrayindices the last step locked; [], never null
parent_round_idstringtrigger round id; also the feature's replay identity
next_step_idstringderived; absent once settled
settlementobjectpresent only once settled

Change control

Any change to a response field invalidates the examples above, the OpenAPI schema and the runtime parity evidence. Three guards fail on it, in this order:

  1. TestHAWLiveResponseFixtures — the live orchestrator output no longer matches the fixture files;
  2. TestOpenAPIHoldAndWinExampleMatchesTheLiveFixture — the published schema example disagrees with the fixture;
  3. TestHoldAndWinNextStateReachesTheWireUnchanged — the handler reshapes next_state on the way out.

Regenerate the fixtures deliberately, then update the OpenAPI example and this document from them, and review all three together before FREEZE & HANDOFF.