The README quickstart, docs/versions.md, hooks.md, rules.md, config.md and listeners.md, and examples/hooks-hybrid-search, each command run against the binary built from examples/base. LLM.md is cut to a current map with test commands and known problems; threat-model, NETWORK, SECURITY and CONTRIBUTING are corrected to the code. Co-authored-by: Hanzo Dev <[email protected]>
19 KiB
Base Network
PQ-native, consensus-replicated HA for every Base app.
One mechanism. One env flag. Replica count N ∈ {1,2,3,…} works
identically on a laptop (docker compose) and on k8s (via
~/work/hanzo/operator). No FUSE, no LiteFS, no Consul.
Durability and replication come from luxfi/consensus/protocol/quasar
— the same DAG consensus that runs Lux validators — applied to SQLite
WAL frames, one DAG per shard. Archive tier is optional cold storage
in S3 (s3://) for PITR.
Problem
Base apps (IAM, KMS, AML, and every app built on Base) each carry SQLite files — often per-user or per-org — and need HA with real durability, no split-brain, and uniform ops across dev / test / main.
Pre-existing options fall short:
- Raw StatefulSet + RWO PVC, replicas > 1 → split-brain, silent DB divergence.
- LiteFS + Consul → three moving parts (FUSE, LTX, Consul) and a single-primary election we don't need; we already have quasar.
- Litestream + single writer → backup, not HA.
- Postgres → violates Base's "embedded SQLite by default".
Goal
- Any Base app opts in with one env:
BASE_NETWORK=quasar. BASE_REPLICATION=1works (standalone, zero consensus overhead, still ships to archive).BASE_REPLICATION=Nworks (full quasar quorum, k = ⌊N/2⌋+1).docker compose up= true HA locally.- Operator emits the env surface + HPA/KEDA from one CR field.
Design
Data model
- A Base app owns many shards. Shard key =
user_id|org_id|<custom header>. - Each shard = one SQLite file on the pod's local PVC (hot cache).
- Each shard is replicated across
BASE_REPLICATIONnetwork members (that shard's quasar subset). Assignment = consistent-hash over live members. - A shard's finalized WAL-frame DAG IS its durability tier. Archive is a cheap, optional second tier.
Shard hierarchy + per-shard encryption
Multi-tenant Base apps produce many shards. The model is two-level physical files plus a fixed logical consensus grouping:
- Physical shard = one SQLite file. Files exist at two scopes:
platform—_platform.db(rare; e.g. IAM app registry).org—<org_id>.db(tenant-wide state).user—<org_id>/<user_id>.db(per-customer state). A single request may touch multiple scopes in one transaction; Base opens the right handle based on the route's declared scope.
- Virtual shard (vshard) = consensus group. A fixed-cardinality
ring (default 256, power-of-two) is hashed over at connection
time:
vshard = hash(shardKey) mod 256. Each vshard has a quasar validator subset of sizeBASE_REPLICATION. All physical files whoseshardKeymaps to vshardVreplicate together. This keeps consensus metadata O(256) instead of O(customers). - Assignment of physical pods to vshards = consistent-hash over live network members. Adding a pod moves ~1/N of the vshards to the new pod; physical files on those vshards migrate via quasar tail-sync (not a file copy — new pod subscribes to the vshard's DAG and materialises from frames).
Encryption at rest is decided a layer up, and not here.
This section used to describe a per-file scheme with its own DEKs, .dekwrap
files beside each shard, an LRU of unwrapped keys and a KEK-of-KEKs — and an
env table for driving it. None of that was ever built, and the variables the
table listed (BASE_VSHARDS, BASE_KMS_URL, BASE_KEK_SCOPE,
BASE_DEK_CACHE_SIZE) are read by no code in this repo. A knob that does
nothing is worse than a missing one: it reads as a control an operator has
already exercised.
What is real: an org's Base opens under a key derived for that org
(plugins/org, hanzoai/cek), so one tenant's file is unreadable with
another's key. The process's own database takes its protection from the volume
it sits on. BASE_ENCRYPT is refused at startup rather than ignored,
because setting it and seeing no complaint is how a deployment comes to believe
it has page encryption.
A frame leaving a pod therefore carries whatever the page held. Treat the replication plane and the archive bucket as holding the same data the database does.
Write path
client ──► gateway (base-network://svc)
│ JWT→shardKey, consistent-hash → writer pod
▼
writer pod
│ handle request, SQLite commit
▼
sqlite3_wal_hook fires frame F
│
▼
network.SubmitFrame(shardID, F)
│ quasar vertex, PQ-signed
▼
quasar finalize (k-of-N ack)
│
├──► apply callback: write F to local SQLite (writer + replicas)
└──► optional: archive validator appends F to S3/GCS segment
No round-trip to a coordinator; no FUSE; no separate leader-election service. A finalized frame IS the commit.
Read path
- Any pod in the shard's subset can serve reads.
- Gateway sets a
txseqcookie with the last-seen finalized seq. - Target pod checks
localSeq ≥ txseq:- yes → serve.
- no → either block briefly on quasar tail (typical < 100 ms) or 307 to a caught-up peer.
Membership
- Every Base pod is a quasar validator.
- Peers discovered from
BASE_PEERSseed list (CSV of DNS), gossip thereafter. - In k8s: headless Service, peers =
pod-0…N-1.svc.cluster.local:9999. - In compose: static DNS names.
- Heartbeat TTL 1.5 s. On miss: the pod's shards are reassigned across survivors; new assignments sync the DAG from peers before accepting writes.
Replication factor
| N | quasar mode | k (acks needed) | failure mode |
|---|---|---|---|
| 1 | standalone | 1 | No HA. Archive = durability. |
| 2 | pair | 2 | 1-down blocks writes, reads survive. |
| 3+ | quorum | ⌊N/2⌋+1 | Byzantine-safe, PQ-signed. |
Same code path for every N; only the k constant changes.
Archive tier (optional)
A witness-only validator runs alongside as BASE_NODE_ROLE=archive.
It does not vote. It subscribes to finalized frames and appends
per-shard WAL segments to object storage. Layout:
<scheme>://<bucket>/<svc>/<shard>/<seq-prefix>/<segment>.lbn
.lbn = Lux Base Network segment — a compact length-prefixed list
of PQ-signed, quasar-finalized frames. Replay any prefix ⇒ SQLite at
that txseq. PITR for free.
The backend is S3 (s3://bucket/prefix), served by hanzoai/s3 or AWS.
off disables the archive, leaving the quasar DAG as the only durability.
Env surface (one shape, every Base app)
| var | values | notes |
|---|---|---|
BASE_NETWORK |
quasar | standalone (default) |
standalone = today's behaviour, no network. |
BASE_SHARD_KEY |
user_id | org_id | header:<Name> |
required when BASE_NETWORK=quasar. Names the shard's ONE source: a field on the verified identity, or the named request header. |
BASE_REPLICATION |
1 | 2 | 3 | … |
≤ BASE_PEERS count. |
BASE_PEERS |
CSV of host:port DNS |
operator-emitted in k8s; explicit in compose. |
BASE_NODE_ROLE |
validator (default) | archive |
|
BASE_ARCHIVE |
s3://bucket/prefix | off (default) |
|
BASE_LISTEN_HTTP |
:8090 default |
Read, but opens nothing; serve --http sets the HTTP address. |
BASE_LISTEN_P2P |
host:port, :9999 default |
quasar p2p listener. The host is honoured: :9999 is every interface, 127.0.0.1:9999 loopback only. |
The R6 backlog caps are constants in network/archive.go: 64 MiB and 100,000
segments per shard. No variable changes them.
The peer transport presents no certificate.
Four names used to stand in this table — BASE_TLS_CA, BASE_TLS_SERVER_CERT,
BASE_TLS_SERVER_KEY, BASE_TLS_ALLOWED_SANS — against a config surface that
no request ever reached. They are refused at startup now, for the reason
BASE_ENCRYPT is: an operator who mounts a keypair, sets one of these and sees
no complaint has been told that peers authenticate.
Wiring it needs two things this repo does not have. The transport is a
luxfi/zap node, which takes one *tls.Config and uses it both for the
listener it binds and for every peer it dials — and Go dials with the config
verbatim, so one config cannot name the several peers it is dialling. That is
upstream. Certificates also need an issuer: the estate's cert-manager builds
internal CAs with pod-DNS SANs elsewhere in the cluster, so the pattern exists,
but nothing issues one for a Base pod today.
Until both land, peers authenticate by reachability — see the trust-set note below.
What the transport and archive enforce
The R-numbers are the labels the env surface above uses; each is glossed here so the table reads on its own.
- R1 (frame/envelope shardID binding): inbound envelopes whose inner
Frame.ShardIDdisagrees withEnvelope.ShardIDare rejected at the transport boundary; the apply loop re-checks as defence-in-depth. - R2 (localSeq monotonic):
Shard.localSeqadvances strictly by +1 per finalised frame. Out-of-order frames buffer (cap 1024). A frame arriving far ahead of the sequence waits there for predecessors that never come, so it cannot advancelocalSeqand the gateway'stxseqcheck cannot read as "caught up". - R3 (archive authorship): segments are
LBN2— Ed25519 signature over body || CRC32 || pubkey. Writers refuse to encode without a signer; readers refuse to decode without a verifier;LBN1hard- rejected as a downgrade vector. - R4 (StatefulSet by default): when
network.enabled: true, the operator emits a StatefulSet (spec.network.workload: StatefulSetdefault) soBASE_PEERSpod-ordinal DNS resolves. Deployment is permitted only atreplicas == 1. - R5 (peer identity): not held. The transport establishes none, and the config surface that read as though it did is gone — see the note under the env table, and the trust-set property below.
- R6 (backlog caps): per-shard cap 64 MiB / 100 k segments,
drop-oldest with
base_shard_backlog_drops_totalmetric. - R7 (HPA workload kind): operator threads workload kind into the
HPA / KEDA
scaleTargetRef.kindso a StatefulSet-backed service autoscales correctly. - R8 (nanos-suffixed object keys):
objectKey(…, startSeq, nanos)produces unique keys per flush;Rangededupes by(startSeq + frameIndex)when multiple segments overlap.
What this layer leaves to you
Standing properties of the design, each with the thing you do about it.
-
BASE_PEERSis the trust set, and reachability is the whole of it. The transport carries no peer identity, so a peer is whatever answers at one of those addresses, and the NodeID it states in the handshake is its own word. Every entry may submit frames for any shard this node owns; there is no second, finer authority inside the set. Keep the list to peers of the same service in the same namespace, and put a NetworkPolicy in front ofBASE_LISTEN_P2P— a cluster that allows all in-namespace ingress puts that port in reach of every pod in it. Treat adding an entry as granting write access to the group's shards. -
BASE_SHARD_KEY: header:<Name>lets the client choose its shard. A header is whatever the caller sends. It exists because compose dev needs a shard key with no token, and it is the only form that reads one — every other form reads the verified identity, so a request carrying none resolves no shard and runs local. Useuser_idororg_idin anything but local dev. The shard picks which pod serves a write, so a caller choosing its own picks its own writer. -
BASE_ENCRYPTis refused. Setting it stops the process with a message naming what actually protects data at rest, rather than starting quietly and leaving the operator to assume. The encryption that is real is a layer up: per-org Base shards open under a per-org key. Put the data directory on an encrypted volume. -
A shard costs an engine. Each holds a 1024-entry channel, and nothing has been exercised past single-digit shard counts. Pick a shard key whose cardinality you can bound —
org_idoveruser_idwhere a tenant is the natural unit — and size before you scale. -
network/attack_vectors_test.gois the executable half of this section. The tests that pass are the defences listed above; the tests that skip name what this package does not defend, and print themselves on every CI run. Thenetwork-attack-suitegate inhanzo.ymlholds the two apart — a defence that stopped holding cannot become a skip, and a marker cannot quietly become an assertion.
Layout
~/work/hanzo/base/network/ (new package)
network/
network.go // NewNetwork(cfg) → *Network; Start/Stop; public API below.
config.go // env parsing, defaults, validation.
node.go // Node identity (stable from pod name or $HOSTNAME).
quasar.go // thin wrapper over luxfi/consensus/protocol/quasar.
wal.go // install sqlite3_wal_hook; frame serialise/PQ-sign.
apply.go // on-finalize callback → sqlite_apply().
shard.go // Shard struct: assignment, local cache, txseq.
router.go // consistent-hash ring; who owns shardID?
archive.go // BASE_NODE_ROLE=archive loop → hanzoai/s3 or GCS.
metrics.go // Prom: base_shards, base_wal_lag_bytes,
// base_hot_shards, base_lease_contentions,
// base_archive_lag_bytes, ...
network_test.go
wal_test.go
router_test.go
integration_test.go // docker compose N=3 smoke: write, crash a node, read, restore.
Public API that Base calls from core/base.go on startup:
package network
type Network interface {
Start(ctx context.Context) error
Stop(ctx context.Context) error
// Installed by Base onto the sqlite *Conn; intercepts commits.
InstallWALHook(conn *sqlite.Conn, shardID string) error
// Given a shardID, returns the current writer endpoint plus
// whether it's the local pod. Gateway uses this.
WriterFor(shardID string) (endpoint string, local bool)
// Any member in the shard subset. Gateway uses this for reads.
MembersFor(shardID string) []string
// Metrics plumbing.
Metrics() *Metrics
}
func New(cfg Config) (Network, error)
Base itself (core/base.go) gains:
if net := network.FromEnv(); net.Enabled() {
b.net = net
b.db.OnConnect(func(c *sqlite.Conn, shardID string) error {
return net.InstallWALHook(c, shardID)
})
}
That is all the coupling. Rest of Base code is unchanged.
~/work/hanzo/operator/ (reconciler update)
Add BaseNetworkSpec struct reused across Base-ish CRDs:
pub struct BaseNetworkSpec {
pub enabled: bool,
pub shard_key: String, // user_id | org_id | <hdr>
pub replication: u8, // ≤ replicas
pub archive: Option<String>, // gs://… | s3://… | off
pub autoscale: Option<Autoscale>,
}
pub struct Autoscale {
pub min: u8,
pub max: u8,
pub cpu_target: Option<u8>,
pub memory_target: Option<u8>,
pub hot_shards_target: Option<u32>,
}
Added to: ServiceSpec, IAMSpec, KMSSpec, and any other downstream
Base-backed CRDs.
New controller.rs helper:
reconcile_base_network(api, spec, meta) -> ();
emits, per replica-managed workload:
- Headless Service (
clusterIP: None) for pod-ordinal peer DNS. - Env on pods:
BASE_NETWORK,BASE_SHARD_KEY,BASE_REPLICATION,BASE_PEERS(computed fromspec.replicas+ headless svc),BASE_ARCHIVE,BASE_NODE_ROLE=validator. - Optional archive sidecar Deployment (
BASE_NODE_ROLE=archive) whenarchive != off. - HPA (cpu, memory) and KEDA ScaledObject (Prom metrics
base_hot_shards,base_wal_lag_bytes) whenautoscaleset. - PodDisruptionBudget:
minAvailable = kwherek = ⌊N/2⌋+1for N≥3,N-1for N=2,0for N=1.
CR shape:
spec:
replicas: 3
network:
enabled: true
shardKey: user_id
replication: 3
archive: gs://hanzo-base-wal/svc
autoscale:
min: 3
max: 7
cpuTarget: 70
memoryTarget: 75
hotShardsTarget: 500
~/work/hanzo/gateway/ (new upstream type)
base-network://<service> upstream.
- Parses JWT
subor configured header → shardID. - Polls
GET /-/base/memberson the service's headless Service every 5 s; caches member list. - Consistent-hashes shardID → owner pod.
- Writes → owner pod. Reads → random pick from
MembersFor(shardID), propagatingtxseqcookie. - On 307 from a not-yet-caught-up replica, retries to another member.
- Passes
X-Base-Shard: <shardID>upstream for logging.
Config example:
upstreams:
- name: my-svc
type: base-network
service: my-svc.hanzo.svc:8090
shardKey: user_id
shardKeySource: jwt.sub
Local dev — docker compose parity
~/work/hanzo/base/compose.network.yml:
services:
base-a: &node
image: ghcr.io/hanzoai/base:dev
environment:
BASE_NETWORK: quasar
BASE_SHARD_KEY: user_id
BASE_REPLICATION: 3
BASE_PEERS: base-a:9999,base-b:9999,base-c:9999
BASE_ARCHIVE: off
ports: ["18090:8090"]
base-b:
<<: *node
ports: ["18091:8090"]
base-c:
<<: *node
ports: ["18092:8090"]
docker compose -f compose.network.yml up → real 3-node HA locally.
Kill one container, writes still succeed, reads still succeed, DB
never diverges. Same code path as prod.
Acceptance tests
- Single-node:
N=1,archive=off→ behaves exactly like today. No regression incore/base_test.go. - Docker compose N=3: write 1000 rows, kill
base-b, read all 1000 frombase-aandbase-c. Bringbase-bback, verify it catches up (txseq matches). No row loss, no duplication. - Archive PITR: write 1000 rows, record txseq, write 100 more. Restore to the first txseq from the S3 archive; DB has exactly 1000.
- k8s operator: apply a Base-backed CR with
network.replication: 3. Verify: headless svc, 3 pods with BASE_PEERS env, HPA, PDB minAvailable=2, archive sidecar whenarchive != off. - Autoscale: drive
base_hot_shards>hotShardsTargetvia synthetic load; verify KEDA scales Deployment up, new pod joins the network, existing shards rebalance. - Gateway: two users in different shards produce writes on
different owner pods; both succeed; read-your-writes respected
via
txseqcookie.
Non-goals
- FUSE. Nothing mounts a fuse filesystem.
- LiteFS. No import, no fork. Pure quasar WAL-hook.
- 3rd-party SQLite processes. This is Base-internal; external apps don't get the WAL-hook.
- Cross-app shard sharing. Each Base service has its own network.
Deprecations
base-hais superseded. Oncebase/networkhas landed and the services on it have migrated, archivebase-hato the deprecations page. The repo stays read-only for history.