Skip to content

Serve a blackboard over HTTP

Agents that run as their own services read and write over HTTP. The operations they need are the operations Control already has, so the library states which path carries each operation and answers it; your service keeps its own HTTP server, its framework, and its authentication.

BoardService.handle takes a method, a path, a decoded body, and the query parameters, and returns a status, headers, and a body. It imports no web framework and opens no socket.

Mounting it

BoardService turns one request into one answer. It takes the store, so every replica answers a read for any board the store holds, and a callable that finds the Control a request names, so a write reaches the callables that board was created with.

service = BoardService(control_for=runs.get, store=store, prefix="/v1")

Writes still need the run, and answer 404 without one. A board that the store never held answers 404 for reads and for writes alike.

blackboard.reader_for(store, board_id) builds the same reader that those reads are answered from, for a caller that wants one of its own without a run. It has the four read operations and nothing else.

Finding the control a request names

on_open and on_closed on create_model are how runs fills and empties:

def opened(model):
    runs[model.board_id] = model.control


model = create_model(
    ...,
    board_id=board_id,
    on_open=opened,
    on_closed=lambda outcome: runs.pop(board_id, None),
)

on_open is given the model and reads the board identifier off it. on_closed is given the outcome, which names no board, so the identifier comes from the call that opened the run.

on_open runs before the first agent is woken, which matters because waking an agent runs its callback on this thread: without that ordering, an agent that reads back through this service meets 404 for the board that is being created.

One route, whatever the framework

from fastapi import FastAPI, Request as Incoming
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse, Response as Bare

from blackboard import Control
from blackboard.server import BoardService, Request

runs: dict[str, Control] = {}
service = BoardService(control_for=runs.get, prefix="/v1")
app = FastAPI()


@app.api_route("/v1/{rest:path}", methods=["GET", "POST", "PUT"])
async def blackboard(rest: str, incoming: Incoming):
    # handle reaches the store, which blocks. On a thread, so it does not
    # block the event loop and every other request on this worker with it.
    answer = await run_in_threadpool(
        service.handle,
        Request(
            method=incoming.method,
            path=incoming.url.path,
            body=await _json(incoming),
            query=dict(incoming.query_params),
        ),
    )
    if answer.body is None:
        return Bare(status_code=answer.status, headers=dict(answer.headers))
    return JSONResponse(
        answer.body, status_code=answer.status, headers=dict(answer.headers)
    )


async def _json(incoming: Incoming) -> object:
    if not await incoming.body():
        return None
    return await incoming.json()
from flask import Flask, jsonify, request

from blackboard import Control
from blackboard.server import BoardService, Request

runs: dict[str, Control] = {}
service = BoardService(control_for=runs.get, prefix="/v1")
app = Flask(__name__)


@app.route("/v1/<path:rest>", methods=["GET", "POST", "PUT"])
def blackboard(rest: str):
    answer = service.handle(
        Request(
            method=request.method,
            path=request.path,
            body=request.get_json(silent=True),
            query=request.args.to_dict(),
        )
    )
    body = jsonify(answer.body) if answer.body is not None else ""
    return body, answer.status, dict(answer.headers)

handle raises nothing you have to catch. A board that this service does not hold, a region that nobody declared, a body that will not decode, and a run that has closed all come back as a status and a body.

The operations

Method Path Answers
GET /boards/{board_id}/regions Every declared region and its kind
GET /boards/{board_id}/levels/{level} A page of that level
GET /boards/{board_id}/premises/{premise} Its current value and version
GET /boards/{board_id}/changes A page of every write, in order
POST /boards/{board_id}/levels/{level} The sequence the write took
PUT /boards/{board_id}/premises/{premise} The version the set produced
POST /boards/{board_id}/acknowledgements Nothing

The paths above are blackboard.wire.OPERATIONS, and the client and the service build them from those objects rather than from a string of their own. Mount the prefix that you gave BoardService; the rest of each path is the library's.

Creating a board is not one of them

There are seven, and none creates a board. create_model is a function you call, not an operation an agent calls, and it could not be one: its arguments include the admission rule and the termination predicate, and a request body carries no callable.

So a service that creates boards on request mounts an endpoint of its own, receives whatever its callers send, and calls create_model with the rules taken from its own code:

@app.post("/incidents")
def open_incident(body: dict) -> dict:
    model = create_model(
        board_id=body["incident_id"],
        store=store,
        regions=REGIONS_FOR[body["kind"]],
        premises={"severity": body["severity"]},
        agents=[
            Agent(
                name=a["name"],
                subscribes_to=a["subscribes_to"],
                address=a["address"],
            )
            for a in body["agents"]
        ],
        limits=LIMITS,
        admission_rule=no_duplicate_findings,
        termination_predicate=until_a_cause_is_agreed,
        reach=notifier.reach,
    )
    return {"board_id": model.board_id}

The agents can come from the request because what an agent is made of is data: its name, what wakes it, what it may write to, and where it is reached. All four are written to the run, so the replica that took this request is not the one that has to serve the next. The callable is the one part that cannot arrive this way, which is why reach is given here and comes from the process.

The regions, the rules and the limits come from the application, whether hardcoded, chosen by a template per kind of work, or looked up. Which of those is right is the application's decision and no part of this library.

The two reads that return a page take limit and from_sequence as query parameters. A read that names no limit answers with blackboard.wire.DEFAULT_LIMIT rows, which is 100. A limit above blackboard.wire.MAX_LIMIT, which is 1000, is capped at that maximum silently. A page says has_more when it stopped early, and the reader continues from one past the last sequence it received. A sequence number is the cursor because an offset shifts when a concurrent write lands.

A read in process takes limit=None and means unbounded, because it is not paying for a page. A read over HTTP is a page, with or without a size from the caller.

What a request body carries

Operation Body Required
POST .../levels/{level} WriteRequest writer, content
PUT .../premises/{premise} SetPremiseRequest writer, value, expected_version
POST .../acknowledgements AckRequest agent, notification_id

content and value are required because a body without the one its operation takes would otherwise store null, and setting a premise to null wakes every subscriber to it.

WriteRequest.level and SetPremiseRequest.premise are optional and are ignored where the path names the region, which it always does on these routes. They exist so that a service mounting a single route of its own still receives a body that says what it is.

What the status codes mean

Status Meaning Body
200 A read, or a write this key had already made What the read asked for, or a WrittenBody with repeated set
201 A write reached the board for the first time WrittenBody
204 An acknowledgment was recorded No body
400 The body or a query parameter could not be read ErrorBody, bad_body or bad_query
404 No such route, board, or region, a region of the other kind, a premise nothing has written, or a notification never issued to that agent ErrorBody, one of no_such_route, unknown_board, unknown_region, wrong_region_kind, unset_premise, unknown_notification
405 That path takes a different method ErrorBody, wrong_method, with an Allow header
409 A premise moved on, or an idempotency key named another region ConflictBody, or ErrorBody naming idempotency_key_reused
410 The run has closed and takes no more writes ErrorBody, run_closed
422 The control component refused the write RejectedBody, whose cause is admission or not_permitted

Every 4xx here is an answer that the blackboard decided rather than a fault in the transfer, so repeating the request is not what resolves it. Only a 5xx and a failure to connect are worth another attempt.

An ErrorBody carries error and detail. error is the stable name to branch on. detail is written for a person reading a log and changes between versions.

409 carries two bodies. A premise write whose expected version is not the current one answers a ConflictBody, which names the version that the premise now holds, and the caller reads the premise and decides again against it. A write whose idempotency key already named another region answers an ErrorBody naming idempotency_key_reused, and sending it again answers the same. A client tells the two apart by the presence of an error key in the body, which is what BoardClient does.

A region that nobody declared is 404 unknown_region, and a level operation on a premise, or a premise operation on a level, is 404 wrong_region_kind. The region names come from the application's own declarations rather than from anything this run decided, so a request naming one that does not exist is answered the same way on every operation, reads and writes alike. In process the same write raises UndeclaredRegionError rather than returning a rejection. The 422 carries what the run decided about a write it understood: the admission rule returned Reject, or the writer is a registered agent whose writes_to does not name that level.

Writing once

A write body may carry an idempotency_key. The blackboard writes one key once: a key that it has already written answers 200 with the first write's sequence and repeated, and adds nothing, whereas a first write answers 201. A key that already named another region is refused 409 with error set to idempotency_key_reused, and in process the same write raises IdempotencyKeyError.

Nothing in the service does that work. The key goes to the control component and from there to the store, where the row and its key are written together. Storage covers what a store guarantees.

The path names the region

A write is addressed to /boards/{id}/levels/{level}, and that is the level it lands in. A body naming a different one changes nothing, so a caller cannot write somewhere it did not address, and a gateway policy written against the path holds.

Authentication

The library has no authentication. The service is behind your gateway, the gateway authenticates the caller, and each operation has a path and a method for a policy to be written against. A read-only agent is given the four GET paths and nothing else.

handle does not check that the writer in a body is the caller. Do that in the route, before calling in, from whatever the gateway put on the request.

Board identifiers

handle matches the path a segment at a time and decodes each variable once, so an identifier holding a slash arrives whole as long as your framework hands over the path it received. Some frameworks decode the path first. A UUID travels through every framework unchanged, and is the identifier to use.

Notifying the other way

This page covers what an agent sends. Notify agents over HTTP covers what the blackboard sends, and that direction needs the notifier extra. blackboard.server needs no extra: it is in the base install and depends on nothing.