Status: Accepted Date: 2026-05-03 Deciders: QNTX Core Team Related: ADR-003 (Plugin Communication), ADR-004 (Plugin-Pulse Integration)
Plugins are separate processes that communicate with QNTX over gRPC. The lifecycle — boot, initialization, health monitoring, restart, shutdown — has implicit contracts that caused real bugs: double initialization, stale connections, silent watcher failures. This ADR documents the full plugin lifecycle and the watcher subsystem as canonical references.
Double initialization. ForceInitialize bypassed initOnce without consuming it. The HTTP routing lazy-init then called Initialize again, causing plugins to start their work twice and tear down what they just built.
WebSocket ping-pong is undocumented. Plugins implementing HandleWebSocket must read the incoming gRPC stream and respond to PING messages with PONG. If they ignore the stream (natural first instinct), QNTX logs "WebSocket pong timeout" and the connection dies.
Watcher lifecycle is implicit. The full path — InitializeResponse.watchers → DB → engine → ExecuteJob — is spread across four files. A plugin developer sees only the proto field and ExecuteJob.
Predicate matching rules are undocumented. Matching semantics (exact, OR, rate limiting) are only discoverable by reading engine.go.
Document the plugin lifecycle and watcher system as first-class concepts.
Binary launch gRPC connect Initialize RPC Health poll (10s)
| | | |
process starts Metadata() Initialize() Health()
binds port validates name plugin starts work monitors liveness
prints QNTX_PLUGIN_PORT returns watchers, restarts on 2
routes, handlers consecutive failures
Metadata() called to validate plugin identityInitialize(InitializeRequest) sent with config, ATS endpoint, auth tokenInitializeResponse with watchers, routes, handlers, schedulesInitialize call. The initOnce guard ensures HTTP routing lazy-init doesn't trigger a second call.ReinitializePlugin / ForceInitialize) is only for config updates on an existing proxy — not for new processes after restart.Initialize handler may be called on a proxy that already has running state from a previous call.am.toml. The config map in InitializeRequest contains key-value pairs from the plugin's config section.Restart = disable (best-effort) + enable. There is no special restart path.
Shutdown(), kill process, prune watchers, unregister handlersInitialize, register everythingThis means a restart always produces a new process, new gRPC connection, new proxy with fresh initOnce.
Health() on each running pluginShutdown() RPC (best-effort, 5s timeout)Plugin QNTX Core Watcher Engine
| | |
|-- InitializeResponse -------->| |
| (watchers: [...]) | |
| |-- SetupPluginWatchers() -------->|
| | (write to DB, idempotent) |
| | |
| |-- ReloadWatchers() ------------>|
| | (load from DB into memory) |
| | |
| | attestation arrives |
| | |
| | <-- predicate match -------- |
| | |
|<-- ExecuteJob(attestation) ---| |
| (handler_name routes it) | |
| |
| Field | Required | Description |
|---|---|---|
id | yes | Unique suffix. Core prefixes with plugin name: {plugin}-{id} |
handler_name | yes | Which ExecuteJob handler receives the match |
predicates | yes | Attestation predicates to match (exact match) |
contexts | no | Additional context filters |
max_fires_per_second | no | Rate limit. 0 = no rate limiting. Default: 0 |
Watchers survive plugin restart. On every Initialize:
SetupPluginWatchers calls CreateOrReplace for each declared watcherReloadWatchers refreshes the engine's in-memory stateQNTX sends PING messages on the gRPC stream and expects PONG responses. This tells the plugin whether a browser client is still connected.
Plugins must read the incoming gRPC stream and reply to PING with PONG (echo the timestamp). Spawn a reader task or thread that checks the message type and responds accordingly.
Failure to respond causes QNTX to log WebSocket pong timeout. The keepalive interval and timeout are configurable in am.toml under [plugin.websocket.keepalive]:
[plugin.websocket.keepalive]
ping_interval_secs = 30
pong_timeout_secs = 60
When things go wrong, QNTX emits:
| Log message | Meaning | Plugin action |
|---|---|---|
Failed to parse AX query for watcher | Watcher predicate is malformed | Fix the predicate string in WatcherRegistration |
gRPC ExecuteJob failed | Plugin returned an error from ExecuteJob | Check plugin-side handler logic |
Max retries exceeded, giving up | ExecuteJob failed repeatedly | Check plugin health, logs |
WebSocket pong timeout | Plugin ignores incoming WebSocket stream | Read the incoming stream and reply to PING with PONG |
Failed to setup plugin watchers | DB write failed during Initialize | Check DB connectivity |
WatcherRegistration proto docs