Skip to content

Session protocol

The session protocol is how any front end plays the games: the web showcase's browser worker, our own service over WebSocket and HTTP, and the command line, which runs on the same session layer. This page lists the messages, the fields of a frame, how play --view json relates to frames, and the versioning rules; recorded sessions are stored in the replay format. The machine-readable form is the JSON Schema src/grounded_decision/session/protocol.schema.json (generated from PROTOCOL_VERSION), generated from the Python dataclasses; the plan behind it is Web showcase: project plan, phase 1. How the browser runs it is The engine in the browser.

The session layer in Python

grounded_decision.session opens a match from a spec and steps it:

from grounded_decision.session import Session, SessionSpec

session = Session.open(SessionSpec("tictactoe", agents=("you", "grounded")))
[move.notation for move in session.legal_moves()]  # ['1', '2', ..., '9']
frame = session.submit("5")  # a person's move, in the game's notation
frame = session.step()  # the agent to move decides and plays
frame.decision.verdict  # Verdict.ACT: every check that ran passed
frames = session.run(10)  # up to 10 moves; stops when a person must move
Session.describe()  # every game, agent, check, world and variant
Name What it does
SessionSpec(game, agents, world, seed, size, players, until_length, rules, you, you_play, guarded, max_steps) Everything that fixes a match. Built and validated by the same code as grounded-decision play (GameModule in games/registry.py), so it refuses exactly what play refuses, with the same message.
Session.open(spec) A session at the starting position.
session.step() The agent to move decides and plays; returns the new Frame.
session.submit(text) A person's move; with guarded=True the gate checks it and may play its safe fallback.
session.run(count) Up to count moves, stopping when a person must move or the game ends.
session.legal_moves() The mover's legal moves, each with the notation that submit accepts.
session.select(cell) A person picks the piece on cell: a PieceSelection with highlights (selected, target_near, target_far) and one legal move per target. Games with select and cell_name hooks (Hexxagon) mark their own targets, as the terminal's selection prompt does; otherwise the targets are the legal moves that start on the cell.
session.frame The current frame.
session.info What stays fixed: seats, world, the panel's rows.
Session.describe() The catalogue: games, agents, regimes, checks, worlds, variants and sizes.

The layer is pure: it does no I/O, starts no threads, handles no signals and reads no clock, so it runs unchanged in Pyodide. A test runs every game with signal, subprocess and multiprocessing blocked and the clock and Thread.start replaced by functions that fail. The command line injects time.perf_counter for its own timing.

Messages

Every message is a JSON object with a type. A request may carry an integer id; the reply repeats it. An Endpoint answers each request with exactly one reply and never raises; Endpoint.handle_text does the same for JSON text (what the browser's worker passes between JavaScript and Python).

Request Fields Reply
describe (none) describe: version (the protocol version) and catalogue: the games and, from 1.2, the regimes (each name, label and description, the words of play GAME --explain)
open spec: a SessionSpec frames: session and the first frame
step count (1 to 10,000, default 1) frames: one frame per move played
submit move: a legal move's notation frames: the frame after the move
select (1.1) cell: a {row, column} of the board selection: the piece, its overlays, a hint and one legal move per target (moves, origin first and target last in cells)
compare (1.3) spec: a CompareSpec (compare's options: game, agents, seeds, first_seed, opening_plies, size, players, rules, world, max_steps); games: indices to play now (at most 100); results: games other engines finished comparison: the plan (format, seatings, seeds and openings, number of games, the identical command), the games played, and the table of every game this engine knows
audit (1.3) spec: an AuditSpec (audit's options: game, agent, opponent, oracle, oracle_depth, seed, size, seat, players, world, max_steps); moves: how many to play now (default 100) audit: the info (agent, seat, line-up, unit, the session's seats and panel, the command), the decisions judged in these moves, and the summary so far
analyse (1.3) (none) analysis: the open session's position analysed by the game's own solvers (index, and an Analysis or null)
learn (1.4) max_risk; sample (how_to or authorisations) or cases (each LearnCase: the evidence fields, proposed_action, action_was_correct, optional support, was_acted_on, case_id); confidence, measure, procedure, seed, grid (a LearnGrid, or null for the default), learn_decision_line learned: status, the dataset, target, chosen settings (as Limits names them), the risk and holdout parts, counts of combinations, explain (learned.explain()), warnings, the fingerprint, parameters_json (what learn --output writes) and every combination's test in candidates
monitor (1.4) scenario (authorisations or outcomes); max_risk, confidence, window; the scenario's settings (authorisations: batches, batch_size, drift_from, seed; outcomes: before, before_rate, after, after_rate, seed) monitored: the target, window, decisions, the monitor after every recorded outcome (steps: counts, bounds, status), the first alarm and drift_step, and the report's lines
runtime (1.4) events (each entity_id, event, payload, candidate_model_score, idempotency_key); enforce (false is shadow mode) decisions: the policy_version, parameters_version, valid_events, the demo data's accounts, and one step per event (state before and after, the evidence, the six checks, verdict, routing, review and outbox numbers, the DecisionRecord as JSON)

An error reply has a code and a one-sentence message, and leaves any open session unchanged:

Code Meaning
message The request is malformed: unknown type, unknown field, wrong type, count out of range.
spec The game, seats, size, rules or world are not allowed (the command line's message), or a demo cannot run with these settings or data.
session Not now: no session is open, a person must move, the move is not understood or not legal, the game is over, no move starts on the selected cell.
internal A bug; the message names the exception.

A spec in JSON uses the field names above; size is text ("20x20" or a named size such as "expert"), rules an object of the options the game's configure takes (mines, preview, board, start, layout, holes, stall_plies …). Unset fields take the defaults of play.

Comparisons and audits (1.3)

compare and audit run the command line's own code (Tournament in games/compare.py, AuditSetup and AuditRun in games/audit.py; design decisions D59 to D61). A comparison's games are numbered: game i is seed seeds[i // len(seatings)] with seating seatings[i % len(seatings)]. A compare request with the same spec as the open comparison continues it: the engine plays the games asked for (a game already known is not played again), takes in the results another engine sent (a result that contradicts a known one is a session error), and replies with the table of every game it knows. The table is tallied in index order, so it depends only on which games it holds, and once it holds them all it equals compare --json: each row's games, wins, draws, losses, cut_off, points, mean_moves, mean_score, overrides, win_rate and its exact Clopper–Pearson interval (low, high), rounded as the command line rounds them. A browser spreads the games over several engines this way.

An audit request with the same spec as the open audit plays on; nothing is played once the match is over. Each judged decision has the move played and the oracle's (chosen, best), their values and the regret in the oracle's unit; a disagreement (positive regret) also has its position: the before board, the chosen_cells and best_cells, and the frame after the move, whose decision the inspector reads. The summary is the command line's report so far (agreement with its interval, total and mean regret, overrides, the worst decisions, the oracle's name, the outcome); at the end it equals the audit object of audit --view json --no-regimes. The regime price table stays in the command line. The catalogue's games list their audit oracles (1.3).

Analyses and lessons (1.3)

An Analysis is one position analysed by the package's own solvers: values (a number per cell, read on its scale: probability, signed, count or none), tags (conclusions about cells, named in tag_names), routes (ordered cells, closed for a cycle) and facts (numbers about the whole position), with a title and a one-sentence summary. Each game with a teaching page defines one (grounded_decision.session.lessons.GAME.analyse): the snake's cycle and certificate, Minesweeper's mine probabilities, the Connect Four solver's column values and so on. A Lesson is a document, not a message: the scenes (real sessions) and positions a teaching page explains, each with its frame and analysis; make lessons writes one per game into the site, and the schema defines it so a front end validates it.

The demos beyond games (1.4)

learn, monitor and runtime run the library's other documented examples for the web showcase's /parameters, /monitor and /runtime pages (grounded_decision.session.demos; design decisions D64 to D68). Each request is complete in itself and its reply has everything to draw; none touches the open game session. They need numpy, which the browser's engine loads only for the pages that send them. The numbers are the command line's: learn runs learning_run (the code of learn_parameters, which also reports every combination's exact test), the authorisations drift is the parameter_learning example's live traffic and prints its report's lines, the outcomes stream is the how-to's example (evenly spaced errors, or seeded draws), and runtime runs the reference service's pure core (examples/reference_core.py) over the demo data, giving POST /decide's routing, versions, review and outbox numbers. A setting or data a demo cannot use is an error with code spec; tests/test_session_demos.py compares every demo with the command line, the reference service and the docs' numbers.

Board sizes (1.9)

A catalogue game's size_offers say which sizes its board may take, so a front end offers only those: none for a fixed size (tic-tac-toe), which it does not offer to change. Each offer's rule is the game's SIZES (games/sizes.py): a width range (low, high or null for no limit, step: checkers' sides are even), a height range or null for one number, the side (NxN: Go's lines, a hexagonal board's side), one_even (the width or the height must be even: snake), the unit a side counts, the default size and the menu of usual sizes, each with an optional label (standard, expert); its description is the rule in one line. The first offer holds unless a later one's option (a rule option, such as board) has that offer's value: a Hexxagon donut needs side 4, and a procedural Breakout layout fits a narrower field than the built-in levels. A spec's size stays WxH (or a named size of sizes); the engine checks it with the same rule, and what no rule can say (a layout read from a file must fit) it still refuses with a reason.

Lapsed guarantees and rule options (1.10, 1.11)

A frame's decision has a lapse (1.10) when the gate cannot certify the move played because no move meets what its checks protect (Breakout: no spin-proof bounce exists; Minesweeper: no cell is proved safe; Connect Four: the opponent forces a win within two whatever is played); empty while the gate's guarantee holds. A front end shows it rather than the regime's promise.

A catalogue game's rules (1.11) are the options its configure takes that a command line sets: each with its name (the keyword), flag (also the URL parameter), label, kind (int, text, switch, choice, or file: a path a browser cannot offer), its choices or span, its values in words (1 to 9), its default, its help, the option values it requires (Tetris's level needs mode realtime) and whether 0 turns it off (zero_is_off). They come from the flag table and the game's OPTIONS (games/options.py).

Transports

The protocol is the same whatever carries it; each transport moves JSON text and nothing else. Every one answers with Endpoint.handle_text's exact text for the same request (the conformance tests compare them byte for byte, tests/test_service.py and web/tests/e2e/service.spec.ts).

Transport Session How a message travels Where
In-process one Endpoint Endpoint.handle(message) or handle_text(text) the command line, tests
Web Worker one per worker postMessage of the text; the worker calls handle_text in Pyodide WorkerTransport (The engine in the browser)
WebSocket /ws/ one per connection one text message per request and per reply, in request order; requests may be sent before the replies come our own service, WebSocketTransport
HTTP POST /api/ a fresh one per request the request as the body, the reply as the body (application/json) our own service

Our own service (grounded-decision serve, Run our own service) adds limits a browser does not need. A request over one is not run; it gets an error reply of the protocol whose message names the limit:

Limit Code id Then
Message larger than GD_SERVICE_MAX_MESSAGE_BYTES (HTTP 413) message null the session stays open
A binary WebSocket message message null the session stays open
step count, audit moves or compare games above the service's maximum message the request's the session stays open
A comparison with more seeds than the service allows spec the request's the session stays open
More messages than the connection's rate session the request's the session stays open
More sessions than the process allows (HTTP 503) session null the socket closes with 1013
A request slower than GD_SERVICE_REQUEST_TIMEOUT (HTTP 504) session the request's the socket closes with 1013: the session is lost

A socket silent for GD_SERVICE_IDLE_TIMEOUT closes with 1001. An error with a null id belongs to the oldest request not yet answered, which the engine could not read; the TypeScript Engine fails that request with it. No new message or code was needed, so the protocol's version did not change (design decision D78).

Frames

A frame is one position with everything a front end draws for it. It is semantic: cells are CellKind values and highlights Overlay values, never glyphs or colours.

Field Contents
index Moves played to reach it (0 at the start).
to_move, awaiting_input The seat to move (null once over), and whether a person must submit.
decision The move and how it was chosen: seat, player, agent, move, proposal, verdict (act, degrade, abstain or null), checks (each name, passed, explanation: the evidence), notes, metrics, regime. Null at the start.
board layout (square, pointy, flat), rows, columns, cells, hex offsets, details (per-cell order, connections, heading, label, tint), overlays (row, column, overlay), coordinate labels, row_labels, column_labels, focus, and (1.2) paths: ordered cell lists, each with a kind, whose cells' details are derived (below).
transition In-between pictures of the move (a board each, and a weight), for animation.
move_cells, proposal_cells The cells the move touched, origin first; on an override, the rejected proposal's cells.
arrows (1.7) The move as arrows: each a role (played, or proposed for an override's rejected proposal), the seat that moved and its cells, origin first. Defined over the previous frame's board, where the piece still stands: the origin holds a piece of that seat and every later cell is free (ArrowRule, session/arrows.py). Empty for placements and drops.
event What the move did in a few words (text, size, big), or null.
panel counters (moves, acts, overrides, ungated), the game's stats, standings with recent history, recent events, the sparkline.
outcome finished, winner, reason, scores, cut_off (the move budget ran out), placings.
legal_moves When a person is to move: every legal move's notation, description, cells and, for a guarded seat, certified.
assumption Outside the random world, why probabilistic statements do not apply; else empty.

A board's paths (protocol 1.2) are ordered lists of cells with a semantic kind; today one kind, body, a connected body head first (the snake's). A reader derives each path cell's note instead of finding it in details, exactly as the terminal views do (CellPath.details in views.py, pathNotes in web/packages/board): cell i of n has order i / (n - 1) (0 for a one-cell path), connections to cells i - 1 and i + 1 when they are neighbours (diagonals included), and the first cell has the path's own heading (1.8: where the head faces, the snake's last move, which a one-cell snake cannot show by its neck) or else the direction from the second cell to it; a note listed in details for the same cell replaces the derived one. A move then changes the path by one cell at each end, where a note per cell changed every cell's order: a 20 × 20 snake frame is built and encoded about 1.6 times faster, and a recorded full snake game is a quarter of the size. A board without paths (older frames) has none.

The panel's fixed rows (checks, note_rows, stats_keys, standing_rows, event_rows, sparkline) come once per session in session.panel, so the web panel keeps the same rows on every frame as the terminal panel does.

--view json and frames

grounded-decision play GAME --view json prints one JSON object per line, in the format it always had, and that format is a documented projection of the frames. The canonical form is:

  • the start line: game, players, agents, and world for games with worlds;
  • one step line per frame after a move: index is the frame's index - 1, player, agent, move, proposal, verdict, checks, notes, metrics and regime are the frame's decision, and stats is the frame's panel.stats;
  • the end line: the match summary (moves, overrides, cut_off, outcome);

each serialised by json.dumps(..., sort_keys=True). A golden test plays every game in the snapshot set on several seeds both ways, through the command line and through Session.step, and compares the output byte for byte.

Generated files

make protocol writes, from the Python source:

File Contents
src/grounded_decision/session/protocol.schema.json The JSON Schema (draft 2020-12), shipped in the package.
web/packages/protocol/generated/protocol.schema.json The same schema, for the TypeScript package.
web/packages/protocol/generated/tokens.json Design tokens: a colour and glyph for every cell kind and overlay, dark and light themes, from the terminal palette in views.py.
web/packages/protocol/fixtures/*.json Golden conversations (requests and replies) for several games.
web/apps/site/static/demos/*.json The demo pages' previews (the engine's replies for the documented examples) and the how-to's history; floats to ten significant digits (D66).
web/packages/protocol/src/generated/types.ts The TypeScript types, generated from the schema (needs make web-setup).

Tests fail when any of them is stale. In TypeScript, Protocol.default().reply(json) validates a message against the schema and returns it typed; Vitest checks every golden fixture with it.

Versioning

The protocol has its own semantic version, PROTOCOL_VERSION in grounded_decision/session/protocol.py, independent of the package's. src/grounded_decision/session/protocol.baseline.schema.json records the schema of the last released protocol version, and a test compares it with the current schema:

Change Kind Version bump
Remove a type, a field, an enumeration value or a union member; change a field's type Breaking Major
A request field becomes required, or a reply field stops being always sent Breaking Major
Add a type, an optional field, a reply field, an enumeration value or a message Additive Minor
Change only descriptions None Patch, or none

Readers must ignore fields they do not know, so additions never break an older peer; the TypeScript validator does this with { strict: false } (the tests validate strictly). At a protocol release, make protocol-baseline records the new baseline.