Governance
Data governance — retention TTL sweep, GDPR export + erasure, consent ledger, and field-level encryption for .encrypted() columns. Builds on audit + soft-delete.
@voltro/plugin-governance is the compliance layer: a periodic retention sweep that deletes or anonymises stale rows, GDPR export + erasure across every table that references a subject, a consent ledger, and field-level encryption for .encrypted() columns. It pairs with @voltro/plugin-audit (who/when) and @voltro/plugin-soft-delete (recoverable deletes).
Wiring
// app.config.ts
import { governancePlugin } from '@voltro/plugin-governance'
export default {
type: 'api' as const,
name: 'api',
plugins: [
governancePlugin({
// Retention: delete/anonymise rows past a TTL on a periodic sweep.
retention: [
{ table: 'events', ttlMs: 90 * 86_400_000 }, // delete after 90 days
{ table: 'users', ttlMs: 365 * 86_400_000, action: 'anonymize', anonymizeFields: ['email', 'name'] },
],
// GDPR: which tables reference a subject + by which column.
subjectScopes: [
{ table: 'users', subjectField: 'id' },
{ table: 'posts', subjectField: 'userId' },
{ table: 'comments', subjectField: 'authorId' },
],
sweepIntervalMs: 3_600_000, // default 1h
}),
],
}Retention
runRetention walks each policy's table and, for rows older than ttlMs (measured from dateField, default createdAt), either deletes them (action: 'delete', default) or nulls anonymizeFields (action: 'anonymize'). The sweep is armed in bindDataStore and stopped on onDeactivate; trigger it manually via the service's runRetentionNow(). On a multi-replica SQL deployment the sweep is cluster-coordinated — it runs on only ONE replica per tick (via the same claim gate the cron scheduler uses), so N replicas no longer each run the full paged scan; single-process / memory / SQLite deployments run it locally every tick. No configuration — it's automatic.
GDPR — export + erasure (admin-gated)
Two admin-only routes (guarded by requireScope(ADMIN_SCOPE)) walk subjectScopes:
governance.export{ subjectId }→ a portable bundle{ [table]: rows[] }of everything referencing the subject.governance.erase{ subjectId, mode? }→ deletes (or anonymises) the subject across every scope; returns an immutableErasureLogEntry({ subjectId, at, mode, affected: [{ table, count }] }).
The same operations are available in-handler via GovernanceService (exportSubject / eraseSubject).
Consent ledger
governance.consent { purpose, granted } records the calling subject's decision; governance.hasConsent { purpose } reads the latest (latest-write-wins per (subject, purpose)).
The default store is in-memory — per-process + restart-cleared, so a consent recorded on one replica is invisible to others (the plugin warns at boot when it runs under this default). For production pass consent: 'datastore' for the shipped durable, cross-instance store: it contributes a _voltro_consent table (via extendSchema, under the store:write permission the plugin already declares) and appends one row per decision, so a consent recorded on any replica is visible everywhere and the full history persists for audit. Or pass a custom ConsentStore.
governancePlugin({ consent: 'datastore' }) // durable + cross-instanceimport { GovernanceService } from '@voltro/plugin-governance'
export default (input, ctx) => Effect.gen(function* () {
const gov = yield* GovernanceService
if (!(yield* Effect.promise(() => gov.hasConsent(ctx.request.subject.id, 'marketing')))) return { skipped: true }
// … send the marketing email …
})Field-level encryption
Flag a column .encrypted() in the schema and fieldEncryption: true registers an AES-256-GCM cipher that the store middleware applies transparently — writes encrypt, reads decrypt, handlers always see plaintext, the column stores an opaque enc:v1:… string on every dialect.
// schema — database/patients.entity.ts
export const patients = table('patients', {
id: id(),
name: text(),
ssn: text().encrypted(), // AES-256-GCM at rest
notes: json().encrypted(), // any column type — JSON-encoded then encrypted
})
// app.config.ts
governancePlugin({ fieldEncryption: true }) // key from secret VOLTRO_FIELD_ENCRYPTION_KEY
// or: fieldEncryption: { secretKey: 'MY_KEY_NAME' }The key resolves through the Secrets-Resolver — pass a 64-hex-char string for raw key bytes, or any passphrase (scrypt-derived). Rules:
- Lose the key, lose the data. GCM authentication fails closed — a wrong/tampered value throws on read, never silently corrupts. The failure is a typed
FieldDecryptionErrornaming thetable.column(not a rawmalformed ciphertextwith no context, and never the ciphertext itself), so you can see WHICH column andEffect.catchTagit server-side. If it reaches the wire UNCAUGHT, it collapses to a genericInternalErrorfor the client (thetable.columnand cause stay in the server log) — never anExitEncodedschema dump. - An
.encrypted()column can't be filtered or sorted by plaintext in SQL (it's ciphertext on disk). Encrypt only what you read back whole — PII, tokens, free-text notes. - Boot fails loud if an
.encrypted()column exists but no cipher is registered (enablefieldEncryption), or iffieldEncryptionis on but the key can't resolve. - Reads of rows written before encryption was enabled pass through untouched (only
enc:v1:…values are decrypted), so you can turn it on incrementally.
Restoring a snapshot encrypted under a different key
Importing a prod/staging DB dump into a dev DB brings ciphertext bound to the
source key — your dev VOLTRO_FIELD_ENCRYPTION_KEY can't read it, and by
default one such row throws a FieldDecryptionError that fails the whole read
(including its readable siblings). For dev/migration only, set:
VOLTRO_FIELD_DECRYPT_ON_ERROR=nullNow an undecryptable column degrades to null with one deduped warning per
table.column (scope store.fieldEncryption) instead of nuking the read — the
row's other columns still decrypt. This is the difference between "re-enter your
Jira PAT" and "the whole section 500s". Never set it in production, where a
key mismatch must fail loud. A cutover tool that writes rows into .encrypted()
columns should NULL those columns on import — the source ciphertext is useless
without the source key, and users re-enter the credential so it re-encrypts under
the local one.
Raw-SQL escape hatch — encryptField / decryptField
Transparent encryption runs INSIDE the ctx.store middleware, so a code path that reaches the DB by raw SQL — an auth strategy writing/reading a session token with no store handle, a one-off backfill — bypasses it. For those, @voltro/runtime exposes the SAME registered cipher standalone:
import { encryptField, decryptField } from '@voltro/runtime'
// write path (raw SQL): encrypt by hand
await sql`INSERT INTO sessions (id, token) VALUES (${id}, ${encryptField(pat)})`
// read path (raw SQL): decrypt by hand
const token = decryptField(row.token) // a non-ciphertext value returns unchangeddecryptField returns a non-enc:v1:… value unchanged, so you can switch a raw-SQL path to encryption while pre-existing plaintext rows keep working until they're re-written. Both throw a clear error if no cipher is registered (enable fieldEncryption). Encryption stops being all-or-nothing tied to going through ctx.store.
Dashboard panel
Both dashboards ship a Governance panel (api apps): retention-policy status + field-encryption state + last-sweep results (with a Run sweep now button), a GDPR runner (enter a subject id → Export / Erase delete / Erase anonymize), a consent-ledger lookup, and the erasure log. Write-actions gate on the canRunGovernance capability. Backed by /_voltro/inspect/plugins/governance/{status,erasures,consent,export,erase,sweep}.
Permissions
store:write (retention sweep + GDPR erasure mutate rows; field encryption rides the store middleware) + inspect:read (dashboard panel).