RFC 0001 — Driver Framework
- Status: Implemented
- Author: Martin Ahindura
- Created: 2026-07-22
- Touches:
qpi-ui(Go/PocketBase), the driver SDKs (Python today), dashboard (React) - Issue: #25
1. The idea
QPI-UI is an extended PocketBase server holding the metadata for the whole app. It
used to talk to exactly one kind of external process — qpi-driver, which runs
quantum jobs. This RFC turns that hard-wired relationship into a framework built on
events: a superuser registers a driver in the dashboard, gets a token and a code
snippet, and writes a driver against an SDK mirroring the events QPI-UI understands.
qpi-driver is that SDK, and also ships official drivers (the QPU one, say) as
extras. QPI-UI already has the server and dashboard halves and a handler for every
event; the author writes only the client half.
2. Vocabulary
| Term | Meaning |
|---|---|
| Driver | An external process registered with QPI-UI that exchanges events with it. Every driver belongs to exactly one QPU; a QPU may have many drivers (one runs jobs, another monitors the cryostat, …), like devices and drivers in an OS. |
| Event | A typed message, { type: EventType, payload: EventPayload }, that flows in either direction. |
| Event type | One of a fixed set defined in a QPI-UI version, each with a server-side handler and a payload shape. Maintainers add more over releases. |
| SDK | The base library, one per language (python, typescript, go), that mirrors a version's event types. You build a driver by inheriting from it. Officially maintained drivers ship as optional extras on top of it (e.g. qpi-driver[cli,qblox]). |
Superseded in part by RFC 0003 — Driver Extensibility. The framework below stands unchanged — a driver still subclasses the SDK base, implements
handle_event, and callsemitandevery. What RFC 0003 changes is how a driver is named and launched: the vocabulary is now operation (what a driver does — a closed set QPI-UI has handlers for) and device (the backend implementing it — an open set anyone can add to), and every driver is started with one verb,qpi-driver start --operation <op> --device <device>. Read RFC 0003 for that layer; this RFC for the framework it sits on.
3. How it works
flowchart LR
Admin["Superuser"] -->|"register: name, QPU, language, event types"| UI["QPI-UI"]
UI -->|"token + install snippet"| Admin
Admin -->|"writes driver with the SDK"| Drv["Driver process"]
Drv <-->|"TLS + NNG, token-authenticated events"| UI
- Register (dashboard): give the driver a name, the QPU it belongs to,
its language (dropdown), and its kind — one of the known official kinds
(
mock,qiskit_aer,quantify,qblox, …) orCUSTOM. - Get a token + snippets. The token is shown once. Alongside it, setup
snippets resolved from the chosen kind × language: for an official build, a
systemd install, a manual CLI run and a plain install-and-run, each prefilled
with the token, address, CA fingerprint, name and the right extra
(
qpi-driver[cli,qblox]). For a pair with no official build (go+qblox, say) or anyCUSTOM, the base install plus a stub to fill in. The kind→extra mapping and which pairs are official live in a static catalog shipping with QPI-UI; a kind with no dedicated extra gets plain[cli]. - Write the driver. Inherit from the SDK base class, implement handlers for
the events QPI-UI may send, and call
emit(event)for events you send up. - Run it. The driver connects with its token over the existing TLS-secured NNG channel. The token identifies the driver — and, because a driver is bound to one QPU at registration, everything it does is implicitly scoped to that QPU.
4. Events
An event is { type, payload }. Delivery is left to NNG (as today); there is no
application-level ACK/NACK — a handler that rejects an event just logs and drops
it, exactly as the current result listener does with a malformed result.
The driver's QPU is known from its record (via the token), so events need not carry it; a handler always acts on the calling driver's QPU. The event types that exist today are just the job flow, generalised:
| Event type | Direction | Payload sketch | Handler does |
|---|---|---|---|
JobDispatch |
UI → driver | { job_id, circuits, … } |
Driver runs the job. (Push, scheduler-driven, as today.) |
JobResult |
driver → UI | { job_id, status, results } |
Updates the job, deducts QPU-seconds; status = completed/failed. |
QPUState |
UI → driver | { state } |
Tells the driver its QPU is online, under maintenance or disabled. |
QPUState is re-asserted on change from the dispatcher loop rather than pushed by
the endpoint that changed it — the PUSH socket lives in that goroutine, and a
level-triggered send needs nobody to remember what was delivered. So a reconnecting
driver or a restarted server is told again, which is why restarting cannot bring a
switched-off QPU back into service.
It carries the state rather than a stop/go instruction because the right response differs by operation: a monitor keeps reporting a fridge under maintenance, a tuner stops its own drift checks but still honours a dispatched calibration. It is cooperative — a driver can ignore it — so the server-side gate remains the enforcement; this only reaches work a driver schedules on its own clock.
New event types are how the framework grows: a maintainer adding, say, a cryostat monitoring driver (which does not exist today) would introduce its own driver→UI event and handler. Such a monitor is a separate driver, not part of the QPU driver.
Illustrative Python SDK shape (the QPU driver):
class MyQPU(QpiDriver): # base class mirrors this version's events
def handle_event(self, event): # act on an event QPI-UI sends,
if event.type == EventType.JOB_DISPATCH: # dispatching on its type
result = self.backend.execute(event.payload)
self.emit(Event(type=EventType.JOB_RESULT,
driver=self.name,
payload={"job_id": event.payload["job_id"],
"status": "completed", "results": result}))
A monitoring driver would be its own class emitting its own event on a timer via
self.every(1.0, …) + self.emit(…) — independent of the QPU driver above.
5. What exists today, and what changes
Grounding, so an implementer copies rather than invents:
- Transport —
qpi-ui/internal/api/nng.go:runDispatcher(PUSH, UI → driver) +runResultListener(PULL, driver → UI), bothtls+tcpviagetListener, withSetPipeEventHookflipping online/offline; lifecycles in theactiveQPUsmap, started byStartQPUDistribution. - Registration/handshake —
handleQPUCreate/handleQPUConnectinapi.go: look up bydb.HashToken, allocate ports viafindFreePorts, return a one-time token +ca_fingerprint; superuser-gated byHasSuperuserAuth(). - TLS — server-owned CA (
internal/config); clients pin the root CA by SHA-256 (_download_root_ca_certinqpi-driver/qpi_driver/driver.py). - Schema —
qpi-ui/internal/db/migrate.go: a new collection = a struct inmodels.go+ oneensure…function reflected overdb:/type:tags. - Driver runtime —
driver.py: handshake → CA download → PULL loop + worker subprocess + result PUSH. TheExecutorABC is the per-event logic in miniature.
What changes: the dispatch/result pair generalises from jobs to typed events,
carried by one envelope (§6). Job dispatch stays push — the scheduler still
decides and QPI-UI sends JobDispatch; nothing about the scheduler or
online-detection changes. qpi-driver becomes the Python SDK, and a QPU becomes
a driver that handles JobDispatch and emits JobResult. All additive. (The
rollout was gated behind an EnableDriverFramework flag, since removed.)
Packaging. qpi-driver grows the same per-language layout as qpi-client
(py, js, go), each holding that language's base SDK. The executors stay put as
opt-in extras shipping ready-to-run drivers — qpi-driver[cli,qblox],
[cli,quantify], [cli,aer] — run exactly as before. To build a new driver you
depend on the base package alone. Nothing changes for an operator installing an
official one.
Buffering: unchanged from today. The database is the only durable store — a
failed JobDispatch leaves the job pending to be re-dispatched; the driver is
marked offline by the pipe hook. There is no app-level queue, and any driver→UI
event is best-effort (dropped if nothing is there to persist it).
Three states, not two. enabled is admin intent; status is liveness observed
from the pipe hook; and the server holds an in-memory lease per connected driver —
two NNG ports, two goroutines, a listener socket. Conflating the last two wedged a
QPU no driver was on, so:
- The lease is released on socket detach, on disable, on delete, and when the listener fails to bind. No grace period: the port pair stays reserved on the record, so a reconnect rebinds the same two.
statusresets toofflineat startup —onlineis an observation by the process that held the socket, so a row surviving a crash describes a server that is gone.
6. Envelope
One JSON shape on the wire (Go DTO beside DispatchPayload in schema.go; SDK
dataclass client-side).
{
"id": "01J...",
"driver": "drv_ab12",
"type": "JobResult",
"ts": "2026-07-22T10:04:05.123Z",
"payload": { } // shape depends on type; validated by the handler
}
driver is informational on the way up and authoritative on the way down.
QPI-UI reads an inbound envelope's driver and then ignores it, keying off the
socket the event arrived on — an event's driver is the one whose NNG port delivered
it, which is not something a driver gets to claim (nng_driver.go). Outbound, the
server fills in the driver's ID; the SDKs put their display label there — the one
drivers/connect returned — so read it as a label in a log, not an identifier.
7. Data model
drivers and qpus stay separate collections; a driver points at its QPU.
drivers(new — struct inmodels.go+ensure…inmigrate.go, copyingQPU) —name(req),qpu(relation →qpus, required),kind(select: official kinds likeqblox/quantify/mock/qiskit_aer/… orcustom),language(select: python/typescript/go),events(json: the event types it participates in — set from the catalog for official kinds, chosen forcustom; drives routing),token(hashed),status(offline/online/maintenance),nng_in_port,nng_out_port,host,version,last_seen,enabled, autodates. A QPU has many drivers; each driver has one QPU.qpus(existing, unchanged) — all current fields stay for the scheduler and booking; no new field needed (the link lives ondrivers.qpu).events(new — the single event log, for tracing what happened) —source(driver id, orserver),driver(relation),qpu(relation),type,payload(json),ts(indexed),created. Every event is recorded here; retention pruning keeps it bounded (§12). Job outcomes still land inquantum_jobsvia theJobResulthandler —eventsis the trace, not the source of truth.
Event types are not stored — they live in code: the Go server has a handler per type, and the dashboard (which ships from the same version) already knows the types and the kind→snippet catalog it needs. No metadata endpoint is required.
8. Job lifecycle (worked example)
- Connect. The driver POSTs its token to the connect endpoint; QPI-UI resolves it to the driver record (hence its QPU) and returns the NNG ports + CA fingerprint. Once the NNG pipe attaches, the pipe hook marks it online — exactly as today. There is no application-level handshake message: identity and QPU come from the token.
One driver per role per QPU. Connect returns 409 while another driver of the
same operation is connected: two process drivers would hand one chip two
schedules, two tuners would each write the device YAML (RFC 0004 §10). By
operation, not kind, so a quantify_tuner refuses a qblox_tuner. custom is
exempt, its operation being whatever its author wrote. Registration is
unrestricted — a standby harms nothing until it connects.
2. Dispatch (push, unchanged). The scheduler picks a pending job for the QPU
and QPI-UI sends JobDispatch{ job } to a driver of that QPU.
3. Result. The driver runs the job and emits JobResult{ job_id, status,
results }. The handler applies it to the calling driver's QPU (no cross-QPU
access is possible), updates quantum_jobs, and deducts QPU-seconds.
Every event above is also written to the events log for tracing (§7).
9. Security
Inherited unchanged: all NNG traffic is TLS; drivers pin the root CA by
fingerprint; tokens are stored hashed; registration/management require
HasSuperuserAuth(). QPI-UI never stores or runs driver code — only metadata and
recorded events — so there is no code-shipping risk; the token is the identity
boundary.
Authorising QPU-scoped events is trivial in this model: a driver is bound to exactly one QPU at registration, so QPI-UI derives the QPU from the token and a driver simply cannot reference another QPU's records. There is nothing to spoof and no per-event ownership check to get wrong.
10. Dashboard
- Register/manage drivers — a full page (GitHub-OAuth-app style) rather than a
modal, since there is now a one-time token plus several setup snippets to show.
Fields: name, QPU, kind, language. On save, reveal the token once and the
kind×language snippets (systemd / manual CLI / install-and-run, or a custom
stub); show status +
last_seen. - Monitor: for drivers that report upward (e.g. a future cryostat monitor),
live charts reading
eventsfiltered by type/QPU via PocketBase realtime.
11. Implementation plan
Complete — see the status at the top. The phased plan it was built from was kept outside the repository.
12. Notes
The events retention window is a qpi.config.yml setting (eventsRetention:
"720h") with env and flag overrides, following the same precedence as other
durations like jobTimeout, so operators tune it per deployment.
The rest of what this draft settled is stated where it applies: no app-level
buffering (§5), one events collection for tracing (§7), and one QPU per driver
with many drivers per QPU (§2, §7).