Skip to content

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 calls emit and every. 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
  1. 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, …) or CUSTOM.
  2. 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 any CUSTOM, 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].
  3. 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.
  4. 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:

  • Transportqpi-ui/internal/api/nng.go: runDispatcher (PUSH, UI → driver) + runResultListener (PULL, driver → UI), both tls+tcp via getListener, with SetPipeEventHook flipping online/offline; lifecycles in the activeQPUs map, started by StartQPUDistribution.
  • Registration/handshakehandleQPUCreate / handleQPUConnect in api.go: look up by db.HashToken, allocate ports via findFreePorts, return a one-time token + ca_fingerprint; superuser-gated by HasSuperuserAuth().
  • TLS — server-owned CA (internal/config); clients pin the root CA by SHA-256 (_download_root_ca_cert in qpi-driver/qpi_driver/driver.py).
  • Schemaqpi-ui/internal/db/migrate.go: a new collection = a struct in models.go + one ensure… function reflected over db:/type: tags.
  • Driver runtimedriver.py: handshake → CA download → PULL loop + worker subprocess + result PUSH. The Executor ABC 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.
  • status resets to offline at startup — online is 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 in models.go + ensure… in migrate.go, copying QPU) — name (req), qpu (relation → qpus, required), kind (select: official kinds like qblox/quantify/mock/qiskit_aer/… or custom), language (select: python/typescript/go), events (json: the event types it participates in — set from the catalog for official kinds, chosen for custom; 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 on drivers.qpu).
  • events (new — the single event log, for tracing what happened) — source (driver id, or server), 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 in quantum_jobs via the JobResult handler — events is 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)

  1. 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 events filtered 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).