Database
Migrations, connection pooling, row-level security, backups, and what to restore when.
One PostgreSQL database, shared by the control plane, the gateway and the worker. PostgreSQL 14 or newer.
#Migrations
Nine migrations, applied in order, forward-only.
| Covers | |
|---|---|
0001 |
Tenancy and people — organizations, workspaces, environments, members, roles |
0002 |
Agents — identities, keys, generations, passports, capability grants |
0003 |
Tools and credentials — the registry, sealed material, leases |
0004 |
Policy and delegation — policies, versions, test cases, bindings, delegations |
0005 |
Risk, approval and trust — signals, events, approvals, evidence |
0006 |
Actions and audit — action records, stage results, the hash-chained trail |
0007 |
Security and protocols — incidents, emergency controls, MCP, A2A, registry |
0008 |
Platform — webhooks, API keys, usage, plans, subscriptions, SSO, SCIM |
0009 |
Row-level security — the policies, applied when enabled |
bashnpm run migrate # apply
npm run migrate:check # apply to a throwaway database and verify it comes out clean
migrate:check is the CI gate. It proves every migration applies to an empty
database — which is the thing that breaks when someone edits an already-applied
migration instead of adding a new one.
Never edit an applied migration
Add a new one. An edited migration has already run on every deployed environment, so the edit silently applies only to new ones and your environments diverge with nothing to indicate it.
A real bug in the migration recording
node-postgres returns an array of results for multi-statement SQL. The
driver was reading .rows off the array, got undefined, and threw — after
the DDL had already committed. The schema was applied but not recorded, so the
next run tried to apply it again and failed on an existing table.
The driver now normalises multi-statement results. If you ever see a migration that "failed" but whose tables exist, that class of bug is what it looks like.
#Pooling
iniDATABASE_POOL_MAX=20
DATABASE_STATEMENT_TIMEOUT_MS=15000
DATABASE_POOL_MAX is per process instance. Three control plane instances,
two gateway instances and one worker at 20 each is 120 connections. Size
PostgreSQL's max_connections for the total, and consider PgBouncer in
transaction mode once you are past a handful of instances.
DATABASE_STATEMENT_TIMEOUT_MS bounds a single statement. The gateway is on the
latency path for every protected action, so a query that runs away must be cut
off rather than holding a connection.
#Row-level security
iniDATABASE_ENABLE_RLS=true
Turns on PostgreSQL row-level security as a second, independent isolation layer beneath the application-level tenant scoping.
Enable it in production
The application layer already requires a tenant scope on every query and refuses without one. RLS exists so that a bug in that layer — a query that forgets its scope, a new repository method that does not inherit the guard — does not become a cross-tenant data leak.
Two independent layers, where either alone would be sufficient. That is the point of defence in depth: you do not have to be confident that the first layer is perfect.
See Tenant isolation.
#The embedded driver
Veefore ships PGlite, an embedded PostgreSQL, selected by PGLITE_DATA_DIR when
DATABASE_URL is absent.
Use it for: isolated unit and integration tests, where a private per-process database is exactly what you want.
Do not use it for: running the stack.
PGlite is per-process
Each process gets its own private copy. The control plane, the gateway, the worker and any seed script would see none of each other's writes.
The symptom is not an error. It is an agent you just created not existing, a
policy you just activated not applying, and a login that works in one process
and fails in another. Set DATABASE_URL.
Related: close() on the embedded driver is now terminal. It previously booted
a fresh empty database on the next query, which made the gateway's readiness
check stay green with no data behind it.
#Backups
Standard PostgreSQL practice, with one Veefore-specific consideration.
bashpg_dump --format=custom --no-owner --no-privileges veefore > veefore.dump
A database backup is not a complete backup
Credential material in the dump is sealed with
VEEFORE_DATA_ENCRYPTION_KEY, which is not in the database. Restore the dump
without that key and every credential is unreadable.
The audit chain has the same property with VEEFORE_AUDIT_CHAIN_SECRET:
restore without it and you can read the events but you cannot verify integrity.
Back up the four keys alongside the database, in your secret manager, and test a restore that uses both. A backup you have not restored is a hypothesis.
Point-in-time recovery is worth configuring. The audit trail is append-only in practice, so PITR gives you a consistent view of decisions as of any moment — which is the thing an investigation needs.
#Restoring
- Restore the database.
- Ensure the four keys match the ones in use when the backup was taken.
npm run migrate— bringing a restored older snapshot up to the current schema.veefore audit verify— confirms the chain survived the round trip.- Start the processes.
Step 4 is the one people skip and it is the one that tells you whether the restore is sound.
#Retention and growth
The two tables that grow without bound are actions and audit_events, roughly
proportional to protected action volume.
Audit retention is configurable, and changing it requires
audit:configure_retention — step-up gated, writing a CRITICAL audit event.
Shortening retention destroys evidence, so a request to "clean up old audit
data" leaves a mark explaining who asked and why.
Export before you shorten:
bashveefore audit export --format csv \
--reason "Pre-retention-change archive" --out archive.csv
The export is watermarked and hashed, so the archive is verifiable later.
#Health
/health/ready on both planes checks database reachability. A readiness probe
pointed at it will pull an instance out of rotation when its database connection
is gone, rather than letting it serve errors.