Skip to content

Add a protocol operation

The web site, the embeddable elements and our own service all talk to the Python engine through one protocol of JSON messages: a front end sends a request such as step or analyse and gets exactly one reply. This page is for a contributor who needs a new kind of request: it goes layer by layer from the Python session code to the page that uses the reply, says which file each layer lives in, which test guards it, and what a reviewer checks before it is merged. It assumes you know the session protocol reference and the development loop.

The worked example throughout is analyse (protocol 1.3): "analyse the open session's position with the game's own solvers", which the teaching pages use to draw Minesweeper's mine probabilities and Connect Four's column values live as a person plays.

The layers

Layer Where analyse
The operation itself, pure Python src/grounded_decision/session/ Analyst.find(game).analyse(session) in session/lessons/__init__.py
Request and reply types session/protocol.py AnalyseRequest, AnalysisReply
Dispatch Endpoint in session/protocol.py a branch of Endpoint._answer
Schema, fixtures, TypeScript types generated by make protocol protocol.schema.json, fixtures/analyse.json, generated/types.ts
Version PROTOCOL_VERSION in session/protocol.py 1.3.0
Client web/packages/engine/src/engine.ts Engine.analyse()
Our own service src/grounded_decision/service/ nothing (section 7)
Model and page web/packages/models, web/apps/site LiveAnalysis.svelte

1. Write the operation as plain Python

Put the work in a class of the session layer, beside what it uses (Session, AuditSession in session/auditing.py, ComparisonSession in session/tournament.py, Demos in session/demos.py), not in the protocol module. It takes and returns frozen dataclasses, and it is pure: the session layer may not read a clock, start a thread, open a file or a socket, or catch a signal, because the same code runs inside Pyodide in a Web Worker. tests/test_session_protocol.py (test_session_modules_import_no_io_threads_signals_or_clock and test_session_runs_with_signals_and_subprocesses_blocked_and_no_clock) fails if it does.

analyse is Analyst: it finds the game's analyse(game, state) in session/lessons/GAME.py and runs it on the session's current state, returning an Analysis (cell values, tags, routes, facts) or None for a game or position without one.

Test it directly first, without the protocol (tests/test_session_lessons.py checks every game's analysis this way).

2. Declare the request and the reply

In session/protocol.py, a request and a reply are frozen, slotted dataclasses with a literal type and an optional id that the reply repeats:

@dataclass(frozen=True, slots=True)
class AnalyseRequest:
    """Analyse the open session's position with the game's own solvers (protocol 1.3).

    Attributes:
        type: ``analyse``.
        id: Repeated in the reply.
    """

    type: Literal["analyse"] = "analyse"
    id: int | None = None


@dataclass(frozen=True, slots=True)
class AnalysisReply:
    """The open session's position, analysed (protocol 1.3).

    Attributes:
        type: ``analysis``.
        id: The request's id, or null.
        index: The frame analysed (moves played to reach it).
        analysis: The analysis, or null where the game has none for this
            position.
    """

    id: int | None
    index: int
    analysis: Analysis | None
    type: Literal["analysis"] = "analysis"

The rules the wire (session/wire.py) enforces when the schema is built:

  • Field types are only str, int, float, bool, None, unions of those with None, Literal strings, string enumerations, tuple[X, ...], Mapping[str, X] and other frozen dataclasses. Anything else fails when the schema is generated (test_unsupported_types_fail_loudly, test_every_message_type_has_a_schema).
  • Every field is documented in the class's Attributes: section: the text becomes the field's description in the JSON Schema and the TypeScript types.
  • A field without a default is required. Give every new field of an existing type a default, or the change is breaking (section 5).
  • A request whose size is chosen by the sender gets a bound, checked in __post_init__ with a ValueError (which becomes a message error): StepRequest.count is at most MAX_STEPS_PER_REQUEST, CompareRequest.games at most MAX_GAMES_PER_REQUEST, AuditRequest.moves at most MAX_MOVES_PER_REQUEST.

Then register both types: add the request to the Request union and REQUESTS, the reply to the Reply union and REPLIES (keep ErrorReply last), and the request's type to Endpoint.TYPES.

3. Answer it in the Endpoint

Endpoint.handle decodes a message into its request type, calls _answer, and always returns a reply: exceptions become an ErrorReply with a code, so a transport only ever moves JSON.

Raised Error code Meaning for the front end
WireError (and ValueError while decoding) message the message is malformed; do not retry it
PlayError, DemoError spec the spec names something the game does not offer
SessionError session not now (no session open, a person must move, the game is over)
anything else internal a bug; the reply names the exception

analyse needs an open session, so its branch comes after the session is None check (which raises SessionError):

if isinstance(request, AnalyseRequest):
    analyst = Analyst.find(session.spec.game)
    analysis = None if analyst is None else analyst.analyse(session)
    return AnalysisReply(request.id, session.frame.index, analysis)

An operation that keeps state between requests (a comparison, an audit) holds it as a field of Endpoint, opened again when the spec changes (Endpoint._compare, Endpoint._audit).

Tests in tests/test_session_protocol.py or the module's own test file (tests/test_session_compare_audit.py::test_analyse_needs_a_session_and_follows_it): the reply's type and id, every error code the operation can produce, and that it follows the session after more moves.

4. Regenerate the files

make protocol

python -m grounded_decision.session (session/artefacts.py) writes the JSON Schema twice (in the package and in web/packages/protocol/generated/), the design tokens and the golden fixtures; with make web-setup done it then writes web/packages/protocol/src/generated/types.ts from the schema. Never edit those files by hand. Add a scripted conversation that uses the new request to CONVERSATIONS in session/artefacts.py, as analyse has:

(
    Conversation(
        "analyse",
        "A position analysed (protocol 1.3): Minesweeper's mine probabilities, "
        "proved-safe and proved-mine cells, after a person's first click.",
        (
            _open("minesweeper", agents=["you"], seed=6),
            _submit("reveal E5", 2),
            {"type": "analyse", "id": 3},
        ),
    ),
)

and add the fixture's file name to the list in web/packages/protocol/src/validator.test.ts, which validates every request and reply of every fixture strictly with the TypeScript validator. tests/test_session_protocol.py::test_generated_file_is_current and test_no_stale_fixtures fail while a generated file is out of date; Vitest's codegen.test.ts does the same for types.ts.

5. Bump the protocol version

PROTOCOL_VERSION is the protocol's own semantic version. src/grounded_decision/session/protocol.baseline.schema.json records the schema of the last released version, and test_the_schema_is_compatible_with_the_released_baseline sorts every difference (SchemaChanges in session/compatibility.py):

Change Kind Bump
A new message, type, optional field or enumeration value additive minor
A new required field in a reply additive minor
A new required field in a request; removing anything; changing a field's type breaking major
Descriptions only none none

A new operation is a minor version (1.3.0 added compare, audit and analyse). Say which version added it in the request's docstring and in the request table of the protocol reference. At a protocol release, record the new baseline:

make protocol-baseline

6. The client: the engine package

The browser runs the same Endpoint in Pyodide: EngineHost (web/packages/engine/src/host.ts) passes JSON text to Endpoint().handle_text and back, so the worker needs no change. Add the request to the RequestBody union in web/packages/engine/src/engine.ts and a method to Engine that names the reply type it expects:

/** The open session's position, analysed by the game's own solvers (protocol 1.3). */
analyse(): Promise<AnalysisReply> {
  return this.request({ type: 'analyse' }, 'analysis');
}

Engine.request assigns the id, validates the reply against the schema and turns an error reply into an EngineError. The WebSocket transport (socket.ts, our own service) carries the same text, so it needs no change either. Test the method in web/packages/engine/src/engine.test.ts against a fake transport.

7. Our own service: usually nothing

grounded-decision serve answers every protocol message with the package's own Endpoint (service/connection.py, ProtocolConnection), one per WebSocket and one per POST /api/ request, so a new operation is served as soon as the package has it. Two things may need a line:

  • Work limits. If the request lets a sender ask for an amount of work (count, moves, games, seeds), the service caps it below the protocol's own maximum: add a setting to ServiceLimits (service/config.py, with its range and its Setting row, so it has a flag and a GD_SERVICE_* variable) and a branch to WorkLimits.check (service/limits.py), which refuses with the protocol's own error before the engine runs. analyse asks for a fixed amount of work and needs none.
  • Time. Every request already runs under request_timeout; an operation that may run long should take a bounded amount of work per request instead, as audit and compare do.

tests/test_service.py checks that the service's replies equal the Endpoint's (test_websocket_replies_equal_the_endpoints); add a case to test_too_much_work_is_refused_before_the_engine_runs for a new limit.

8. The model and the page

A page never builds requests itself: a DOM-free class in web/packages/models (or the component that owns the live engine) calls the Engine method and turns the reply into what the page draws. LiveAnalysis.svelte (web/apps/site/src/lib/components/) calls engine().analyse() after every move and hands the Analysis to AnalysisLayer.svelte; Add a web page covers the rest. An end-to-end test in web/tests/e2e/ drives the page against the built site; the teaching pages that use analyse are opened, checked for errors and run through axe in phase5.spec.ts.

Definition of done

  • [ ] The operation is a pure method of a session-layer class, tested without the protocol.
  • [ ] Request and reply dataclasses, documented field by field, bounded where the sender picks the work; added to Request/REQUESTS, Reply/REPLIES and Endpoint.TYPES.
  • [ ] Endpoint._answer branch; every error code it can produce is tested.
  • [ ] A conversation in CONVERSATIONS, make protocol run, the fixture listed in validator.test.ts.
  • [ ] PROTOCOL_VERSION bumped (minor for an addition) and the request table in docs/reference/protocol.md updated.
  • [ ] Engine method and RequestBody entry, with a unit test.
  • [ ] A WorkLimits entry and setting if the sender chooses the amount of work.
  • [ ] The model or component that uses it, and an end-to-end test.
  • [ ] CHANGELOG entry; make check WEB=1 and make web-e2e green.