API · Governance

Data governance in one plugin with @voltro/plugin-governance — AES-256-GCM field encryption for .encrypted() columns (handler sees plaintext, ciphertext at rest), admin-gated GDPR governance.export/erase over declared subjectScopes, a consent ledger, and retention TTL sweeps. Memory store, zero infra.

Four compliance primitives, one plugin. @voltro/plugin-governance gives you field encryption (AES-256-GCM for .encrypted() columns — handlers see plaintext, the column holds enc:v1:… at rest), GDPR export/erase (admin-gated, walking declared subject scopes), a consent ledger, and retention sweeps (delete/anonymise rows past a TTL). The template boots zero-infra on a key voltro dev mints for your project — no key ships with it. Template id: api-governance.

Scaffold

voltro create-project acme --api=api-governance

No encryption key ships with this template — a key committed to a template is a key published to everyone who downloads it. VOLTRO_FIELD_ENCRYPTION_KEY is declared with generate: 'hex', so voltro dev mints a unique one into a gitignored .env.local on first boot. Your deployment mints its own: voltro secret generate field-encryption.

Field encryption

// database/schema.ts
export const profiles = table('profiles', {
  id: id(), name: text(), email: text(),
  // TWO markers, two different questions — say both when you mean both.
  ssn: text().encrypted().serverOnly(),   // encrypted AT REST · and may never leave the server
}).with(audit())

// app.config.ts
governancePlugin({ fieldEncryption: true /* reads VOLTRO_FIELD_ENCRYPTION_KEY */ })

You pass plaintext; the store middleware encrypts on write and decrypts on read — handlers never touch the ciphertext. Boot fails loud if an .encrypted() column exists but no cipher is registered.

.encrypted() is not an exposure marker, and reading it as one is a tempting category error: the runtime decrypts for the handler, so an encrypted column reaches a client exactly like any other unless it is also .serverOnly(). That is why the column above carries both, and why profiles.get returns the last four digits it derived server-side — proof the cipher ran, without publishing the value.

ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"profiles.create","input":{"name":"Ada","email":"ada@acme.com","ssn":"123-45-6789"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d "{\"tag\":\"profiles.get\",\"input\":{\"id\":\"$ID\"}}"
# → { ok:true, result:{ …, ssnLast4:"6789" } }
#   The handler read "123-45-6789" as plaintext (the cipher decrypted it) and
#   published four digits, because the column is also `.serverOnly()`.
governancePlugin({
  fieldEncryption: true,
  subjectScopes: [{ table: 'profiles', subjectField: 'id' }],     // GDPR walks these
  retention: [{ table: 'profiles', ttlMs: 365 * 86_400_000, action: 'delete' }],
})
  • GDPRgovernance.export / governance.erase are admin-gated (call as a subject with admin:full — see api-rbac); they walk subjectScopes to bundle or erase everything belonging to a subject. GovernanceService exposes the same in-handler.
  • Consentgovernance.consent (a mutation) records a grant; governance.hasConsent (a reactive query) checks it — subscribe to it on the web, or call GovernanceService.hasConsent(...) in a handler.
  • Retention — the sweep deletes (or anonymizes) rows whose age past dateField (default createdAt) exceeds ttlMs.

Rules

  • Don't encrypt what you filter on. An .encrypted() column is ciphertext on disk — no WHERE / ORDER BY in SQL. Encrypt fields you read back WHOLE (PII, tokens, notes).
  • The key is everything. GCM fails closed on a bad key — you get an error, never silent corruption. Back the key up; rotating it means re-encrypting.
  • Three orthogonal markers, three questions. .encrypted() = encrypted at rest · .serverOnly() = may it leave the server at all · .sensitive()/.safe() = may it appear in a voltro data export. Using one to answer another's question is the mistake.
  • Every procedure declares an access decision. guards: [{ scope }] or openAccess: '<why>', or the app refuses to boot (security.defaultDeny). Both procedures here are openAccess with the reason in the file: the template configures no auth strategy and no rbac, so every caller resolves to an anonymous Subject holding no scopes and a scope guard would deny 100% of traffic — an outage, not security. Add an identity (see api-auth / api-rbac), then the guard.
  • GDPR endpoints are admin-only by design. Don't remove the admin:full gate — an unauthenticated export/erase is a data-exfiltration / griefing hole.