The protocol is product-neutral.
dbinspect_bridgeis its reference server and Fanlight is a client of it, but neither is privileged: any client that speaks what is written here can drive any server that does, and this document is the whole contract between them.
Status: FROZEN at v1 — 2026-08-05, M0.
Freezing is what makes the M3 and M4 acceptance criteria mean anything: the conformance suite written against the Dart bridge at M1 must pass unmodified against the JVM and Rust bridges. A protocol that drifts while three implementations chase it gives three subtly different products.
Additive changes — a new optional field, a new
capabilitiesentry, a new errorkind— are allowed within v1 and must be recorded in the log below. Clients ignore unknown fields; servers must not require them.Breaking changes — removing or renaming a field, changing a type, changing a method's meaning — require
proto: 2and are out of scope for v1. If one looks necessary during M1–M9, that is a plan-level decision, not an edit to this file.
Date Change Milestone 2026-08-05 Frozen at v1. M0 2026-08-06 watch/unwatchreturn{"watching": <bool>}. §4 described the notification and never said what the call itself answers with, so this fills a gap rather than changing one; a client that ignores the body is unaffected.M2 2026-08-10 fetchBlob(§2.1) and thecapabilities.blobflag that advertises it. §2 has said since M0 that the full blob "is fetched only by explicit user action per cell" and never said how — the same shape of gap as the row above. Additive: a client that never calls it sees no change, and a bridge that does not have it answers thebadRequestan unknown method already gets.—
JSON-RPC 2.0. One method set, two transports.
| Transport | Used by | Framing |
|---|---|---|
| WebSocket | live bridge (Dart / JVM / Rust) | one JSON-RPC message per WS text frame |
| stdio pipes | sidecar-rust, sidecar-jvm |
newline-delimited JSON on stdin/stdout |
Nothing above the transport layer knows which one it is using. A sidecar is a bridge that happens to run locally. This is what makes one grid, one editor, one console and one conformance suite serve all five back ends.
Version negotiation: hello carries proto: 1. A client seeing an unknown major refuses to connect and says so.
docs/THREAT-MODEL.mdis binding on this section. The bridge holds a live, already-opened database handle inside the developer's application, so the pairing secret is the only control in the system.
Pairing applies to the WebSocket transport only. Sidecars are trusted because of how they are launched — absolute path inside the launching client's own install directory, no PATH resolution, no inherited LD_LIBRARY_PATH/DYLD_*, database path on stdin only, no socket — and that construction is a requirement, not an observation.
The connection is the session. There is no bearer token anywhere in this protocol. A connection that has not paired may call nothing else; a paired connection stays paired until it closes. Nothing to leak, nothing to expire, nothing to replay on another connection.
Must be the first message on a WebSocket connection.
{"jsonrpc":"2.0","id":1,"method":"pair","params":{"code":"J8K4M2QX"}}{"jsonrpc":"2.0","id":1,"result":{"paired":true}}The code is 8 characters of Crockford base32 (40 bits), regenerated per bridge session, printed to the console/logger inside a distinctive banner so it is findable in a noisy log. Off loopback, the banner also carries the SHA-256 fingerprint of the session's self-signed TLS certificate, which the client pins on first connect.
Comparison is constant time. Rate limiting is per bridge session, not per connection — otherwise an attacker gets a fresh budget per socket. A failed attempt delays its own response with exponential backoff (1 s, doubling, capped at 60 s); after 10 failures in a session, pairing is closed until the application restarts. Backoff delays responses rather than refusing new connections, so it cannot be turned into a lockout against the developer. Already-paired connections are unaffected throughout.
Before a successful pair, the only error a client can observe is {"kind":"auth"} with no detail — an unpaired prober learns nothing about paths, pids or configuration.
{"jsonrpc":"2.0","id":2,"method":"hello"}{"jsonrpc":"2.0","id":2,"result":{
"proto": 1,
"appName": "example_app",
"runtime": "dart",
"deviceModel": "Pixel 7",
"bridgeVersion": "1.0.0",
"adapters": [
{
"id": "nitrite-main",
"kind": "nitrite",
"displayName": "Nitrite (Hive)",
"engine": "hive",
"capabilities": {
"sql": false,
"watch": true,
"watchScope": "engine",
"edit": false,
"snapshot": false,
"query": "filter",
"filterOps": ["eq","ne","gt","gte","lt","lte","in","notIn","exists","text"]
}
},
{
"id": "sqflite-app",
"kind": "sqflite",
"displayName": "app.db",
"engine": "sqlite",
"capabilities": {
"sql": false,
"watch": false,
"edit": false,
"snapshot": false,
"query": "sql"
}
}
]
}}runtime is one of dart, jvm, rust. kind is one of nitrite, sqflite, drift, hive. engine names the storage engine where one applies (hive, mvstore, rocksdb, fjall, memory, sqlite).
A native Android bridge reports runtime: "jvm", and puts the handset in deviceModel. No android value was added: the client already has the device string it would use to label the connection, a fourth enum value would be a wire change for a distinction nothing acts on, and runtime names the language a bridge is written in — which is what decides whether the JVM core or the Rust one is running. kind and engine are unaffected, since Nitrite on Android is the same Nitrite.
capabilities.query selects the client's console — sql for the sqlite family, filter for Nitrite, key for Hive. The six engines share the grid, the row editor, the export, the snapshot and the diff; only the query surface differs, and the server names which one applies rather than the client inferring it from kind. filterOps is present only when query is filter.
Note the second adapter: sql: false with query: "sql" is not a contradiction. query says which console to show; sql says whether rawQuery is permitted, and it is false until the developer sets allowRawQuery. A default sqflite adapter browses and pages perfectly well with no raw SQL at all.
Note what is not broadcast: appName, deviceModel, bridgeVersion and adapters are returned here, after pairing, and never in an mDNS TXT record. Advertising them unauthenticated hands every peer on the subnet a target list and a version number to look up. mDNS, when enabled at all, carries a random per-session instance name and proto only.
Capabilities are deny-by-default. edit, sql and snapshot are false, and regex is absent from filterOps, unless the embedding developer opted in on that adapter (allowWrite, allowRawQuery, allowSnapshot, allowRegex). The example above is a default-constructed bridge: readable, and nothing more. An operation that is off is absent from capabilities, not merely refused at call time.
capabilities.filterOps is authoritative. The client greys out operators the server does not list. The three Nitrite implementations are not assumed to have identical filter support — each adapter reports what it actually implements, verified against its own filter package. Spatial, full-text and vector filters are out of scope in v1 and must simply be absent from filterOps, never silently mistranslated into something else.
{"method":"listStores","params":{"adapterId":"nitrite-main"}}{"result":{"stores":[
{"name":"users","kind":"collection","approxCount":51204},
{"name":"org.example.Order","kind":"repository","approxCount":812},
{"name":"org.example.Order","kind":"repository","key":"archive","approxCount":40}
]}}kind is collection, repository, table or box. Keyed repositories — first-class only in nitrite-rust, reachable in Java and Dart via getRepository(type, key) — are modelled uniformly here as a key attribute, so the difference stays inside the adapter.
box is Hive's, and it is a fourth kind rather than a table for a reason. A box is a key/value store with no schema and no columns; reporting it as a table is exactly where "Hive looks like a grid" starts, and the client's own rendering hangs off capabilities.query being key and this kind agreeing with it.
approxCount may be omitted when counting is expensive; the UI shows "—" rather than guessing.
{"result":{
"columns":[
{"name":"_id","type":"id","nullable":false,"pk":true},
{"name":"age","type":"int","nullable":true,"pk":false}
],
"inferred": true,
"sampledDocs": 50
}}SQL stores return a real schema with inferred: false. Nitrite and Hive stores have no fixed schema, so the adapter samples 50 documents and must set inferred: true. The UI labels inferred schemas visibly — a developer must never mistake a sample for a guarantee.
{"method":"queryPage","params":{
"adapterId":"nitrite-main","store":"users",
"filter":{"and":[{"field":"age","op":"gt","value":30}]},
"sortBy":"age","desc":false,
"page":0,"pageSize":200
}}pageSize is capped at 200 server-side; a larger request is clamped, not rejected, and the response reports the clamp.
{"result":{
"rows":[{"_id":"…","age":42,"avatar":{"__blob":"iVBORw0…","len":204800,"truncated":true}}],
"total": 51204,
"hasMore": true,
"elapsedMs": 38,
"pageSizeClamped": false
}}Values are JSON-safe. Binary becomes {"__blob": <base64>, "len": <bytes>, "truncated": <bool>}, truncated at 64 KB. The full blob is fetched only by explicit user action per cell — never automatically, and never for a whole page.
The other half of that truncation: one cell, one byte range, by explicit user action.
{"method":"fetchBlob","params":{
"adapterId":"app", "store":"users", "rowId":42, "column":"avatar",
"offset":0, "length":1048576
}}{"result":{"data":"iVBORw0…","offset":0,"len":204800,"eof":true,"lengthClamped":false}}rowId is the same identity §3 addresses a row by, and is refused the same way when it is missing or is neither a string nor a number. offset defaults to 0 and length to the server ceiling of 1 MB; a larger length is clamped, not rejected, exactly as pageSize is, and lengthClamped reports it. len is the real size of the whole value and eof says whether this slice reaches it — a client wanting the rest asks again with a higher offset, the same way it pages a store.
A row or column that is not there is a badRequest, not an empty data: a client asking for a cell that does not exist has a stale grid, and a zero-byte answer would be indistinguishable from an empty value. A column that holds something other than bytes is a badRequest for the same reason — a toString() handed back as a file is how a value gets saved over by its own description.
It is not capability-gated. queryPage already returns every row of every store to a paired client, so the 64 KB truncation is a frame-size limit rather than a control; bulk extraction is what snapshot is gated for, and this is one cell by explicit action. capabilities.blob is advertised so a client can grey out the control on an adapter that cannot address a single row — a sqlite table with no primary key, say — rather than offering a button that always fails.
A value the wire cannot carry becomes {"__lossy": <string>, "type": <class name>}. This is Hive's case and only Hive's: a box holds whatever the application's TypeAdapters registered, and the client has none of those classes. An adapter sends the object's own toJson() where it has one — that is the developer's serialisation and is rendered as itself — and this wrapper otherwise. The flag is on the wire rather than left to the client to infer, because a toString() that arrived looking like data is how a value gets silently written back over by its own description; a client must render it as a description and refuse to edit it.
A filter on a key console is not the filter DSL. Nitrite's operator tree (§4.1 of PLAN.md) has no meaning for a box: there is no field to name. A Hive adapter accepts {"prefix": <string>} and {"from": <string>, "to": <string>}, any combination, all compared as text and the range inclusive at both ends — and refuses anything else rather than returning an unfiltered page. A filtered page omits total: counting it means walking every key a second time, and a number the client prints as a fact must not be guessed.
Paging maps to each implementation's native skip/limit, all of which were verified to exist: FindOptions.skipBy/limitBy (Java), FindOptions(skip:, limit:) (Dart), skip_by/limit_to (Rust), LIMIT/OFFSET (sqlite).
Capability-gated on capabilities.sql, which is false unless the embedding developer set allowRawQuery. SQL for the sqlite family; rejected with kind: "forbidden" on Nitrite and Hive adapters, which use filter on queryPage instead.
{"method":"rawQuery","params":{
"adapterId":"sqflite-app",
"sql":"SELECT id, name FROM users WHERE age > ?",
"params":[30],
"page":0,"pageSize":200
}}The result is queryPage's, minus total: a statement is not a store, so there is nothing cheap to count and a number the console would print as a fact must not be invented.
{"result":{"rows":[{"id":7,"name":"…"}],"hasMore":true,"elapsedMs":4,"pageSizeClamped":false}}params are bound, never concatenated. Paging works by wrapping the statement in SELECT * FROM (…) LIMIT ? OFFSET ?, so a SELECT the developer already limited pages within their own limit. EXPLAIN and the write statements cannot be subqueries and so are run as written; a write answers with the single row {"changes": <n>}, which is what a console needs and all a write has.
The server enforces an allow-list over a real tokenizer, never a deny-list of keywords:
- Read mode permits
SELECT,WITH … SELECT,EXPLAIN. Nothing else. ATTACH,DETACH,PRAGMA,VACUUMand extension loading are refused always, in read and write mode alike.- One statement per request; anything following the first
;is a rejection, not a second statement. - Write mode is a separate server capability, not a client dialog. It is
capabilities.edit— the adapter'sallowWrite— rather than a flag of its own: "this client may change rows" is one decision, and splitting it into two would let an application open the SQL console's writes while believing it had only opened the row editor's.
Built at M7 (dbinspect_bridge lib/src/sql_gate.dart), and three rules came out of writing the tokenizer rather than out of the design:
- DDL is refused in both modes. Write mode is "edit the rows", not "edit the database".
CREATE,DROP,ALTERandREINDEXare in nothing this product asks for, and a dropped table is not an undo away. - The always-refused words are matched anywhere, not only at the front, because
load_extensionis a function call in the middle of aSELECT. The cost is a false refusal for a schema that genuinely has a column calledpragma, and the escape hatch is the one SQL already has: quote it. A bound parameter (:pragma) is not a bare word and is not refused. EXPLAINdoes not launder the statement after it.EXPLAIN DROP TABLE tnever executes, so refusing it is stricter than it has to be — and predictable, which is worth more at a gate than permissive.
The deny-list this replaces was aimed at the wrong threat. ATTACH DATABASE '/path' reads any file the host application can read and VACUUM INTO '/path' writes one anywhere it can write — arbitrary file access in the developer's process — and neither is an UPDATE, a DELETE or a DROP. A keyword list also loses to comment and whitespace tricks, which is why the tokenizer is not optional.
The console still requires typed confirmation for a destructive statement. That is a usability gate against the developer's own mistakes; it is not a security control, and it is not what stands between a client and the filesystem.
Resolved at M1 — the tokenizer stays. Two independent reasons, either one sufficient. sqflite_common's DatabaseExecutor does not expose sqlite3_stmt_readonly at all; its readOnly is an open-time flag on the database, not a property of a prepared statement. And the flag would not be enough even where it is reachable: SQLite's documentation states that sqlite3_stmt_readonly() returns true for ATTACH and DETACH, because they change connection configuration rather than file contents — so gating on it would admit precisely the arbitrary-file-read primitive of F6. Nothing on the wire changed; no freeze-log entry.
All three are gated on capabilities.edit, which is false unless the embedding developer set allowWrite on that adapter — writing into a live application's database is a deliberate decision, not a default. All three are refused on an offline connection opened from a copy, with an error explaining why rather than a generic failure. Every accepted mutation writes one line to the host application's logger — store, operation, row id, timestamp — so a developer can see after the fact what was changed in their database.
| Method | Row identity |
|---|---|
insertRow |
— |
updateRow |
sqlite: rowid or primary key · Nitrite: _id · Hive: box key |
deleteRow |
as above |
_id is the document-id constant in all three Nitrite implementations (Constants.DOC_ID in Java, docId in Dart, DOC_ID in Rust), which is precisely why one method serves all of them.
{"method":"insertRow","params":{
"adapterId":"sqflite-app","store":"users",
"values":{"name":"ada","age":36}
}}{"result":{"changes":1,"id":7}}{"method":"updateRow","params":{
"adapterId":"sqflite-app","store":"users","rowId":7,
"values":{"age":37}
}}{"result":{"changes":1}}deleteRow takes store and rowId and nothing else. All three answer {"changes": <n>}.
changes: 0 is an answer, not an error. The row the client addressed was not there. A client must be able to tell "the row is gone" from "the write failed", and inventing a failure for the first hides which of the two happened.
id is present on an insertRow result where the engine reports one — the value the client can address the row by afterwards. insertRow takes no rowId: the engine assigns the identity, an explicit key goes in values, and a request that carries one is refused rather than inserted somewhere it did not ask for, because a client that sent a rowId believes it addressed a row.
values carries JSON scalars only in v1 — string, number, boolean, null. A blob or a nested document is refused with kind: "badRequest": the grid renders one as a description of itself, and writing back what a renderer produced is how a value is silently replaced. This is a v1 limit on the editor, not on the protocol, and widening it is additive.
Row identity is the store's own key, never a synthetic one. For sqlite that is the single-column primary key — which is the rowid for an INTEGER PRIMARY KEY. A table with no primary key, or with a composite one, refuses updateRow and deleteRow with kind: "badRequest" naming which case it is: queryPage returns SELECT *, which does not carry a rowid, so the client has no identity to send and an edit addressed by anything else would be a guess. insertRow needs no identity and works on such a table.
A column name is checked against the store's own schema. An identifier cannot be parameterised any more than a table name can (§2), so the defence is the same allow-list — names the engine itself reported — and not escaping.
Built at M7 part 2 (dbinspect_bridge lib/src/adapter.dart, WriteRequest). The capability check and the shape check are the core's, in WriteRequest's constructor, for the reason the rawQuery gate is: an adapter is never handed a mutation the embedding developer did not opt into, and an adapter that forgot the check would still pass every test it had. An adapter that advertises edit and does not implement writing answers forbidden — the honest failure — rather than succeeding silently.
{"method":"watch","params":{"adapterId":"nitrite-main","store":"users"}}{"jsonrpc":"2.0","id":3,"result":{"watching":true}}Both are idempotent per connection: watching a store twice subscribes once —
the client re-queries per notification, so a doubled event is pure waste — and
unwatching a store nobody is watching succeeds. unwatch answers
{"watching": false}. A watch on an adapter that does not report
capabilities.watch is refused with kind: "forbidden", decided from the
capability rather than by asking an adapter that has no change feed.
The server then pushes notifications (no id):
{"jsonrpc":"2.0","method":"change","params":{
"adapterId":"nitrite-main","store":"users","event":"insert","at":1754380800000
}}event is one of insert, update, remove, indexStart, indexEnd, upsert, tableChanged.
The first five are the event types all three Nitrite implementations expose. The last two exist because the non-Nitrite engines report change at a coarser grain, and flattening them into the Nitrite five would mean inventing information:
| Adapter | Emits | Why |
|---|---|---|
NitriteAdapter ×3 |
the five Nitrite types | native |
HiveAdapter |
remove when BoxEvent.deleted, else upsert |
BoxEvent carries only {key, value, deleted} — it cannot distinguish an insert from an update |
DriftAdapter |
tableChanged |
tableUpdates() yields a Set<TableUpdate> per table, not per row |
SqfliteAdapter |
nothing — watch is absent from capabilities |
sqflite has no change feed at all |
Since the client re-queries rather than applying deltas, the coarser events cost nothing in correctness. They exist so the label stays true.
capabilities.watch is not a boolean about quality. A DriftAdapter reports watch: true but sees only writes made through its own Drift instance — not another isolate, not another process. It therefore also reports "watchScope": "in-process", against "engine" for Nitrite and Hive, and the client labels the UI accordingly. A developer debugging a cross-process write is precisely the person most likely to be misled by an unqualified "Live" badge.
The client re-queries the current page on a change. Row deltas are not streamed in v1. This is deliberate: delta streaming means reconciling ordering, filtering and paging against a moving store, and getting that subtly wrong shows the developer data that was never in their database. Re-querying is slower and always correct. Refreshes are coalesced to at most one per second.
Subscription handles differ per implementation — a String id in Java, the listener itself in Dart, a SubscriberRef in Rust — and each adapter is responsible for holding its handle and unsubscribing cleanly on unwatch and on disconnect. A leaked listener in a developer's application is a bug in the bridge, not an acceptable cost.
Gated on capabilities.snapshot, false unless the developer set allowSnapshot. This is the one method that returns an entire store in a single call — a bulk-exfiltration primitive if pairing ever fails — so the same limits that apply to queryPage apply here: blobs truncate at 64 KB, one snapshot at a time per connection, and the bridge logs the store name and row count to the host application's logger. Careful per-page limits mean nothing next to one method that dumps everything.
Streams the store as NDJSON in chunked responses, so neither side holds it in memory:
{"jsonrpc":"2.0","method":"snapshotChunk","params":{"requestId":7,"seq":0,"rows":[…],"done":false}}The response to the call only acknowledges it. A store of any size takes longer than the request deadline, so snapshot answers {"store": …, "requestId": …} and the rows follow as the notifications above. requestId is the id of the snapshot call — a notification carries no id of its own, and this is how a chunk names the call it belongs to. seq starts at 0 and is consecutive; a client that sees a gap has rows missing or doubled and must drop the snapshot rather than write a file from it. Registering the chunk sink before sending the call is the client's business: a bridge may start pushing before it answers.
A snapshot that fails partway sends done with an error. There is no response left to put it in — that was spent on the acknowledgement — and silence would leave the client waiting on a timeout for a condition the user could fix.
{"jsonrpc":"2.0","method":"snapshotChunk","params":{"requestId":7,"seq":9,"rows":[],"done":true,"error":"store is locked by another process"}}The client writes chunks straight to an NDJSON file under app-data /snapshots/, named by a locally generated UUID — a store name comes from the server and a server is untrusted, so it is display data and never a path component. The diff engine later stream-sorts two such files by key and merge-joins them — a client's snapshot/diff budget — Fanlight's is 100k rows and 500 MB — exists because of exactly this path.
Standard JSON-RPC errors with a typed data:
{"jsonrpc":"2.0","id":9,"error":{
"code":-32000,
"message":"store is locked by another process",
"data":{"kind":"locked","detail":"Fjall keyspace at /path is held by pid 4821"}
}}kind is one of:
| kind | meaning |
|---|---|
adapter |
the underlying database rejected the operation |
forbidden |
capability not granted (edit on read-only, SQL on a document store) |
badRequest |
malformed params, unknown store, unsupported filter operator |
locked |
the store is held by another process — offline mode only |
auth |
not paired, or a failed pair |
password |
wrong password for an encrypted store |
internal |
anything else; always logged server-side |
locked and password are separate kinds rather than folded into adapter on purpose. Both are conditions the user can actually fix, and a generic "could not open database" for either is the difference between a two-minute fix and a support email.
Before pairing, auth is the only kind that exists, and it carries no detail. The example above names a path and a pid — useful to a paired developer, a free map to an unpaired prober.
The client is the party being fed data here, and mDNS is unauthenticated: any host on the subnet can advertise _dbinspect._tcp claiming to be the developer's app. The protocol therefore constrains the client as well as the server.
- No server-supplied string ever becomes a filesystem path. Store names, adapter ids and column names are data. Files are named locally. This single rule closes the whole traversal class.
- Frames are bounded. Reject a frame over 8 MB without buffering it.
- Responses are cross-checked against the request.
rows.length <= pageSize; a blob's decoded length matches itslen; counts are non-negative;columnsis bounded. A server that contradicts its own request is a fault, not a rendering problem. - Pin on first connect (TLS fingerprint from the pairing banner), so a spoofed mDNS record cannot silently redirect a connection the developer already trusts.