ADR-024: Parquet Storage Backend (DuckDB)

Date: 2026-07-13 Status: Accepted Target: v0.29.0

Context

ADR-023 introduces backend selection but leaves sqlite as the only choice. This ADR adds parquet as the second value: state lives as Parquet files, queried by an embedded DuckDB.

Choosing Parquet is not about mirroring SQLite for durability — it's a distinct backend with a distinct storage model. When backend = "parquet", SQLite is not opened.

Not true yet. A backend = "parquet" deployment opens both: startup runs ats-sqlite migrations and ats-duckdb migrations in one process, because only attestations and access tokens have a parquet implementation. Everything else — watchers, canvas, aliases, embeddings, schedules, WebAuthn credentials — is still served by SQLite, so the process holds two stores at once. make parity prints which things are where. This paragraph describes the intended end state; the split closes as each thing moves.

The backend is named for the format, not the location. First target is AWS Lightsail with S3; local disk is supported for development. Other clouds (GCS, Azure Blob) are out of scope.

Decision

Add parquet as a value for [storage] backend.

Implementation (per ADR-023's pattern): a new Rust crate crates/ats-duckdb embeds DuckDB and implements the same storage traits as ats-sqlite. Go accesses it through CGO at ats/storage/duckdbcgo. No Go-side DuckDB binding — Rust owns the DuckDB C library, one process, one lifecycle.

The crate is named ats-duckdb (not qntx-parquet) because it wraps DuckDB. Parquet is the on-disk format the backend writes; DuckDB is the runtime dependency the crate embeds. If DuckDB is later used for another purpose, the same crate is reusable.

Configuration:

[storage]
backend = "parquet"

[storage.parquet]
location = "s3://bucket/prefix"
# or: "file:///var/lib/qntx/parquet"

location is a URL. Supported schemes: s3:// (production, AWS Lightsail with S3), file:// (development). No credentials field: the AWS SDK's default credential chain resolves them (IAM role on Lightsail, env vars, ~/.aws/credentials, etc.). QNTX does not read secrets from am.toml.

Namespace is the top-level prefix. Every path below is <location>/<namespace>/<kind>/… — "everything is part of a namespace", "nothing falls outside of it", "namespace isnt, pick and choose". A deployment always has two: system and default. That makes isolation structural rather than remembered — a watcher in namespace B does not fire on an attestation in A because it has no path that reaches A, and a schedule created in A stays in A for the same reason.

Attestations stream as Parquet files under <location>/<namespace>/attestations/year=YYYY/month=MM/day=DD/hour=HH/{uuid}.parquet. Immutable, append-only. Hourly partition granularity is a chosen default, not a config knob — it balances predicate pushdown (fewer partitions to scan) against small-file count (more partitions = more small files). Revisit only if a real workload forces the question.

Not implemented. DuckdbStore::flush writes flat: <location>/<namespace>/attestations/{millis}-{uuid}.parquet, with no partition path. Reads glob the same prefix to match. Every predicate scans every file. The partitioning above is still the intent — it was specified and never built, and nothing surfaced that until the bucket was listed by hand.

Multi-value fields (subjects, predicates, contexts, actors) store as Parquet LIST<VARCHAR> — a native DuckDB type that round-trips through Parquet's LIST logical type. Reads run through DuckDB's read_parquet(...); predicates push down through Parquet row-group statistics.

All other state (watchers, canvas, aliases, node identity, WebAuthn credentials, watcher execution queue, scheduled jobs, storage events, etc.) lives under its namespace at <location>/<namespace>/, in a prefix named for the SQLite table it stands in for — make parity pairs them by name, and a second name would read as a second thing. No store opens without being told which namespace it is. Shape per class:

Node identity is none of these. One object at <location>/system/node_identity/self.json, written once at first boot, holding an ed25519 private key. A rewrite is not an update — it mints a new DID and orphans every signature made under the old one. It is also the only secret in the store, so a bucket policy written for attestation data does not cover it.

The system namespace is a literal rather than a DID because this object names the identity every other namespace is keyed by: you would have to read it to know where to read it.

Signatures are unchanged — signing is over canonical JSON (ats/signing/signing.go:86), format-independent.

Fresh start. No migration from an existing SQLite database.

No Parquet format knobs exposed. Compression, row-group size, page size, column encodings are hardcoded to DuckDB's defaults inside the backend. Add knobs only when a real workload forces the question.

No distillation, no bounded-storage enforcement, no compaction. Parquet storage is unbounded; the SQLite-era pressure that made these necessary is gone. Small-file accumulation is accepted; if it ever becomes a real cost, compaction is a separate future ADR.

Vector data (embeddings, cluster centroids, embedding projections, cluster tracking) is out of scope for this ADR.

Multi-node writes. No per-node enforcement counters, no single-DB-file lock. Multiple QNTX nodes write to the same location; each writes uniquely named objects. No coordination needed because no compaction runs.

Dependencies

Exact version pins live in flake.nix (for the C library and toolchain) and crates/ats-duckdb/Cargo.toml (for the Rust binding). This ADR does not restate them.

No Go DuckDB binding. All DuckDB access is through the Rust crate.

Consequences

Minimum performance floor

The backend must sustain, at bare minimum:

"Written" here means accepted by the API — attestations may still be in-memory buffer waiting for flush. Durability latency (time from accept to Parquet file landing) is a separate measurement, bounded by the flush interval.

The floor runs in two places:

Neither passes → don't ship. CI fails → block the branch. CI passes but Lightsail fails → still don't tag; investigate the Lightsail-specific piece (network, IAM, region, extension autoinstall).

Once the floor is holding, subsequent work ratchets it up incrementally to find the current ceiling. That value informs the next round of "Open" decisions (index needed? flush cadence? batching?).

This is a floor, not a target — real workloads may need much more. The floor exists to catch obviously-broken configurations before they reach a running deployment.