blindrange

Build on blindrange — SQL

Shaped like SQL, deliberately not SQL. The verbs are familiar so you can start in minutes; everything the engine genuinely cannot do is refused with the reason, never accepted and quietly wrong. Every sample is quoted from examples/sql_quickstart.py, which the test suite runs. Prefer the full API? — the Python guide.
  1. Install and connect
  2. CREATE TABLE — BLUR is the privacy budget
  3. INSERT
  4. SELECT — WHERE, ORDER BY, LIMIT
  5. COUNT and APPROX SUM — nothing decrypted
  6. UPDATE and DELETE — what happens underneath
  7. Documents and counters — KEY and NEXT VALUE
  8. What is refused, and why — read this one
  9. Going further

1Install and connect

Python 3.10+. Upgrade pip inside the venv first — anything older than pip 21 fails on this project with a misleading message about setup.py.

git clone https://github.com/alviso/blindrange && cd blindrange
python3 -m venv .venv
.venv/bin/pip install --upgrade pip && .venv/bin/pip install -e .

.venv/bin/python -m blindrange.sql        # interactive REPL

Or from code — one connection is a directory of tables, one passphrase, one network:

from blindrange.sql import connect

con = connect(
    "~/data/shop",                        # tables live under this dir — back it up
    "correct horse battery staple",       # unlocks local keys; never sent anywhere
    ["seed.blindrange.dev:7501"],         # any one live peer; gossip finds the rest
    network_secret="blindrange-public",   # which network (anti-vandal, not access control)
)
rows = con.execute("SELECT ...")          # every statement returns a list of dicts
Reads are local. The connection keeps a complete encrypted mirror of each table beside your state directory: lookups, ranges, counts and "not found" all answer from local disk at local speed, while writes go to the network for durability. You configure nothing. Opening a large existing database does a one-time full sync first.
Nobody can recover this for you. Each table's master key lives under that directory, locked by the passphrase, and every key on the network is an HMAC under it. Lose both and the data is gone — the same property that stops the storage provider reading it, pointed at you. Back up the directory; the network is not a backup of it.

2CREATE TABLE — BLUR is the privacy budget

CREATE TABLE orders (
  amount   INT BITS 20 BLUR 64,
  day      INT BITS 16 BLUR 16,
  status   TEXT(6) BLUR 16,
  customer STORED
)
declarationwhat it meanswhat the network can tell
INT BITS 20 BLUR 64 values 0…1,048,575, queryable by range. BLUR is the resolution the network is allowed to distinguish — a power of two, and the dialect refuses to pick one for you, because it is the one decision with a privacy consequence. which 64-wide band a value falls in — never finer, however many queries an observer watches
TEXT(6) BLUR 16 prefix-searchable on the first 6 characters (= 'x' and LIKE 'abc%'). a coarse band of the 6-character prefix; never the string
STORED carried in the sealed record, returned on read, never indexed. nothing. It does not exist on any node in any form
Coarser BLUR is more private AND cheaper. Queries fetch whole buckets of BLUR width, so the bucket is a hard floor on what any observer resolves — and fewer, wider buckets also mean fewer index entries per record. Resolution is what costs money here, not privacy. CREATE takes a moment: each table is a full database with its own master key, not a catalogue row.

3INSERT

INSERT INTO orders (amount, day, status, customer) VALUES
  (450, 201, 'paid', 'cust-001'),
  (120, 205, 'refunded', 'cust-002'),
  (720, 210, 'paid', 'cust-003'),
  (455, 202, 'paid', 'cust-004')

The column list is mandatory, every indexed column must be provided, and out-of-range values are refused — each of those is a way a row could otherwise become silently unfindable by your own queries, which is the worst failure a blind index can have. You never call drain(): reads always see your own writes.

4SELECT

SELECT * FROM orders WHERE amount BETWEEN 300 AND 500
SELECT customer, amount FROM orders
  WHERE amount BETWEEN 300 AND 500 AND day <= 201
SELECT * FROM orders WHERE status LIKE 'ref%'
SELECT * FROM orders ORDER BY amount DESC LIMIT 2

Conditions combine with AND and narrow each other. Text matching is = or a trailing-% LIKE — the index is a prefix index, and equality still checks the full value on the decrypted row, so 'paid' never matches 'paidX'.

ORDER BY … DESC LIMIT n is how you ask for the newest n — ascending with a limit answers with the oldest matches, which is a different question.

5COUNT and APPROX SUM — nothing decrypted

SELECT COUNT(*) FROM orders WHERE amount BETWEEN 0 AND 1000
{'count': 4, 'basis': 'exact-to-leaf'}
SELECT APPROX SUM(amount) FROM orders
{'sum': 1790.0, 'plus_minus': 128.0, 'rows': 4}

Both are answered from index metadata: nothing fetched, nothing decrypted, cost independent of how many rows match. The answers say what they are — basis tells you a count is exact to BLUR granularity (deleted rows still count until compaction runs), and a sum arrives with its error bar, because the resolution you traded for privacy is exactly the uncertainty. Plain SUM() is refused rather than dressed up as exact.

6UPDATE and DELETE — what happens underneath

UPDATE orders SET status = 'shipped' WHERE amount = 450
DELETE FROM orders WHERE day > 208

There is no in-place edit of a sealed record — a node cannot modify ciphertext it cannot read. UPDATE is delete-then-insert, in that order on purpose: a crash between the two leaves a duplicate you can see, never a hole you cannot. There are no transactions, and pretending otherwise would be worse than saying so.

DELETE removes rows from every subsequent read immediately and leaves tombstones behind; compaction reclaims them automatically in the background once enough pile up (PRAGMA AUTOCOMPACT OFF if you want manual control, COMPACT orders to force one). Automating this is recent and deliberate: compaction now survives its own crash and resumes, so a process dying mid-compaction no longer wedges anything.

One rule from the field: after any integrity incident, converge by wiping with integrity enforced — a wipe under a compromised view deletes only what that view could see, and leaves the rest as orphans.

7Documents and counters

Two things applications always need, built in so nobody reinvents them:

CREATE TABLE docs (
  id     KEY,
  body   STORED
)
INSERT INTO docs (id, body) VALUES ('doc-1', 'hello')
SELECT * FROM docs WHERE id = 'doc-1'

A KEY is an opaque handle: exact-match lookup, no BLUR decision to make, auto-generated (and returned) when you omit it. The handle itself lives only inside the sealed record — the network stores a pseudorandom bucket, so the most it can ever tell is that two rows share a handle. It has no order, and asking for one is refused rather than answered with hash order.

SELECT NEXT VALUE FOR invoice_no
{'value': 1}   ·   then {'value': 2} — from any writer, never a duplicate

Sequences are network-atomic: each value is a slot claimed by insert-if-absent, arbitrated by the replicas — the same mechanism that elects one compactor per epoch. Unique and monotonic across writers; gaps are possible (a crash after claiming spends the number), which is exactly how auditors expect invoice numbers to behave.

8What is refused, and why

This table is the honest half of the product. Nothing in it is a missing feature that is coming later — each row is an architectural boundary, and the refusal text in the REPL says the same thing.

statementwhy notdo instead
JOIN records are sealed blobs on nodes that cannot read them; there is nothing server-side to join ON query each table, join in your application — where the plaintext already is
OR the index intersects predicates; a union means running both sides one query per branch, merge yourself — you should see that cost
SUM() aggregates come from metadata, accurate to BLUR — an "exact" sum would be a lie with confident formatting APPROX SUM(col), which carries its error bar
GROUP BY the only grouped shape the index supports is value buckets histogram() in the Python API
LIKE '%x' the index is a prefix index; an inner wildcard needs a scan of data no node can read trailing % only, or filter decrypted rows in code
WHERE on a STORED column nothing about it exists on any node — that is the point of STORED declare it INT/TEXT to query it, accepting the (bounded) leakage
transactions UPDATE is delete + insert; there is no coordinator to promise more idempotent writes; a crash costs a visible duplicate, not a hole

9Going further

The Python guide is the same system with the full API: streaming reads in bounded memory, histograms, multi-device invites, and sharding across master keys when one client's compaction memory becomes the ceiling. Everything there composes with tables created here — a table is an Owner underneath.

And step 8 of either quickstart prints what a node actually holds for the rows you just wrote:

I:0003444dfc39de14496d98436571a361  →  S/4cP9LvVoA=
# no table names, no column names, no values, no ordering —
# and 'cust-001' appears nowhere on any node

Read the threat model before trusting it with anything: it is explicit that this is not for healthcare or high-stakes PII, and why.

blindrange.dev · the Python guide · demos · the web guide · built on it · audit log · S3 gateway · live network · source