Skip to content

What this version does not do

Every limit here is a limit of the library as it stands, checked against the code rather than remembered. Where something is designed and not built, this page says so and points at the design.

A callback belongs to one process

The record is durable and so is the run. What is not is the callback.

Word Holds
The record Regions, contributions, premise values and their versions, the sequence, idempotency keys, and the run's outcome with the agents that did not finish. Removed only by store.delete.
The run The two deadlines, its agents with what wakes each and where it is reached, how far each has got, and what a write recorded that nothing has sent. Removed when the run closes.
The callables admission_rule, termination_predicate, clock, on_open, on_closed, and the transport that reaches an address. Never written, so never removed.
In flight Notifications HttpNotifier has queued but not yet sent.

A write is served by any process. It lands on the record, pushes the idle deadline, and records how far the agents that should hear of it have been told. A process that never registered an agent still knows that agent is owed an answer, and close_expired names it when the run closes.

Whether it can then reach the agent depends on how that agent was declared. An address is data, so every process reads it and any process given a transport as reach delivers to it. A notify callback is a Python object, so the process that holds it is the only one that can call it.

An agent declared by callback alone is therefore woken from one process, and that process finds the work by reading the record, through Control.notify_due, which delivers what its own agents are owed and names them. Call it on whatever schedule suits the deployment, beside close_expired. A write taken in the same process notifies inline and needs no poll, so a run inside one process never calls it.

The delay between a write on one replica and such an agent hearing of it is therefore the poll interval plus whatever is left of the region's batch window. Choose the interval and the idle limit together: a run whose idle limit is shorter than the poll interval can settle before the poll notices. An agent with an address on the run is woken by the replica taking the write and pays neither, unless the region it subscribes to carries a batch window. A window is held in a pending set, and a process holding no callable for an agent holds none, so a windowed region records the intent and leaves the sending to the relay rather than notifying on every write.

Two replicas holding the same agent name both hold a callback, and both deliver. A notification carries no values, so a repeat costs the wire nothing, but it costs an application whose callback does real work. Give one name one callback in one place, or an address and no callback.

A notification is sent at least once, and may be sent twice

The intent to notify is a row written in the same transaction as the contribution, so a process that commits a write and stops before delivering has not lost it. Control.relay sends what is unsent, through a callback this process holds or through the address the run records, and marks a row only after the send returns.

Marking after sending rather than before is what makes delivery at least once. A process that sends and stops before marking sends again, so a notification may arrive twice. That costs nothing: a notification carries no values, the agent reads the board either way, and cumulative acknowledgment absorbs the extra identifier.

HttpNotifier still holds its own queue in memory, and close waits up to close_timeout before abandoning what is left and reporting each one through on_failure. Abandoning it loses the work only where the notifier was given no store: a lane returns before the send, so the lane is what clears the row, and a notifier with no store leaves that to the control component, which sees the lane accept the notification and never learns what became of it.

The library logs only what a caller cannot see

The rule is one line: an agent knows what it wrote, what it was refused, what it was notified of and what it acknowledged, because each of those reached it, so this library says none of them again. It logs a run closing with its outcome and the agents that did not finish, and a notification that never arrived after its attempts, because no agent can log what it never received.

It also logs what only the store layer sees: a record written for a schema this version cannot read, a record stamped forward to this one, and a store that failed under close_expired. A sweep has no caller waiting on it, so a failure there reaches nobody unless the library says so. One board failing does not end the pass; the sweep logs it and closes the rest.

Every line goes to the blackboard logger through the standard library, and the application configures the handlers and the format.

Every store call blocks

BoardStore has nineteen methods and not one is a coroutine, so every write, read, acknowledgment and sweep waits for its database round trip. The record and the run are both in the store, so those round trips are the ordinary path rather than an occasional cost.

An application built on asyncio therefore has to keep them off its event loop, because a blocking call inside async def stalls every other request on that worker. FastAPI's own guidance is to declare a route that calls a blocking library with plain def, which runs it in a threadpool; a route that must stay async def puts the call on a thread with fastapi.concurrency.run_in_threadpool, which is what Serve a blackboard over HTTP shows.

The agent half has both shapes already, BoardClient and AsyncBoardClient, so an agent may be written either way. The blackboard half has one.

There is no authentication and no authorisation

BoardService authenticates nobody. It does not check that the writer named in a body is the caller who sent it, and it has no notion of which agents may write where beyond the writes_to an application declared.

Each operation has its own path and method so that a gateway in front of the service can carry those policies, which is the deployment this library is built for. Check the caller in your route, before calling into the service.

Content must be JSON

Content crosses every store as JSON, including the in-memory one, so what is written comes back as what JSON carries: a tuple comes back as a list, and content that JSON cannot carry raises TypeError before anything is stored.

That is deliberate rather than pending. A store that held Python objects as they stand would accept in a test what a deployment then refuses.

An idempotency key is not compared against the write it names

A key already written returns the first write's outcome whatever content arrives with it. Only the region is compared, and a key that already wrote one region and is then sent naming another raises IdempotencyKeyError, which over HTTP is a 409 whose body reads {"error": "idempotency_key_reused"}.

Comparing bodies means normalising what JSONB and BSON did to them on the way in, which is where a false mismatch would come from. A retry is expected to send what it sent before.

A read over HTTP stops at a thousand

A read in process takes limit=None: model.reader.read_level returns every contribution from the sequence bound. A read over HTTP is bounded twice. It answers with wire.DEFAULT_LIMIT, a hundred, when the caller names no limit, and never with more than wire.MAX_LIMIT, a thousand, whatever the caller asks for.

The cap is silent. A request for five thousand is answered with a thousand and no error, and has_more is the only thing that says the level continues. A caller that reads a whole level follows has_more and moves from_sequence past the last sequence it saw. BoardClient.read_level and BoardClient.read_board do that themselves when they are given no limit; given a limit, they make a single request and return at most that many contributions.

A batch window is not on the record

A store holds a region's name and its kind. It holds no batch window, so read_regions returns every region with the default window of zero, whatever window was declared, and wire.RegionBody.declaration rebuilds it the same way on the other side.

The window is neither the record nor the run, so every process serving a board states its windows again in the regions it passes. Names and kinds are checked against the record and nothing else, so a window that disagrees with another process's is not reported.

Two versions run side by side only when the schema says so

A store records two numbers: the schema it wrote, and the oldest library that can still read what it wrote. A database that is merely newer is used; one whose second number is above what this build knows is refused.

That is what decides whether a rolling deploy works. A release that only adds a column or a table leaves the second number where it was, so the old and new builds run against one database while the rollout proceeds. A release that gives something a new meaning raises it, and then the two cannot run together and the deploy has to stop everything.

One number could not tell those apart, so it had to refuse every newer database and made every schema change a full stop.

A record is never stamped backwards

A store refuses a record written for a schema it cannot read, and never rewrites one to an earlier schema. If it did, an older version of the library would read fields a newer one wrote and take them at face value.

Upgrade forwards. There is no supported path back.

The blackboard reaches out; the agent does not

An agent is notified by a request the blackboard makes to an address the agent gave it, so an agent has to be reachable at an address. There is no long poll, no stream, and no queue an agent subscribes to. An agent behind a network that will not accept an inbound request cannot be notified by this library.

Closing a run clears what it needed

The outcome and the agents that did not finish are stamped into the run as it closes, so everything that write consulted goes with it: how far each agent had been told and had answered, and any notification a write recorded that nothing has sent. A closed board holds its regions, its contributions, its premise values, and one row saying how the run ended.

An acknowledgment arriving after that finds nothing owed. The run has ended, so it changes nothing and reports nothing, which is what it would have changed had the row still been there.

Deleting is the application's to schedule

store.delete removes one board. Nothing in the library calls it: a run that closes deletes no board, and no board expires. There is no retention policy, no sweep, and no age after which anything is deleted.

store.delete also cannot see a live run. Close the run before deleting the board it is serving.

A tool schema does not name the board's regions

blackboard.tools renders one set of schemas, and they are the same whatever board they are used against. blackboard_write takes a level as a string, and nothing in that schema says which levels the board holds, so a model learns them by calling blackboard_read_regions or by being told in the prompt it was given.

A model that names a region the board does not hold is answered with the ones it does, so the correction costs a turn rather than ending the run.

The library calls no model API

blackboard.tools renders schemas and runs the calls a model asked for. It sends nothing to a model, holds no conversation, and depends on no provider's package. The loop, the prompt, and the choice of model are the application's, and Give an agent a language model shows the loop that runs around this module.

The conformance suite defines behaviour, not performance

blackboard.conformance decides a store's correctness. It says nothing about how fast a store is, how it behaves under load, or how many boards it will hold. Those are yours to measure against your database.

The sweep does not poll for what a write path missed

A pass runs close_expired and relay_unsent. Control.notify_due is not among them: it is a method rather than a function over a store, and it answers for the agents one process holds callables for, so a sweep that ran it would be running it for whichever Control a board resolved to rather than for every process. An application whose agents are reached by callback schedules that one itself, beside the sweep.

What is designed and not built

Nothing. Every part of the design is built: the run's deadlines and its outcome, the agents of a run and how far each has been told and has answered, the loop that closes what nobody is watching and sends what nothing has sent, and the outbox that keeps a notification a process was holding when it stopped.