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 immutable ErasureLogEntry ({ subjectId, at, mode, affected: [{ table, count }] }).

The same operations are available in-handler via GovernanceService (exportSubject / eraseSubject).

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-instance
import { 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 FieldDecryptionError naming the table.column (not a raw malformed ciphertext with no context, and never the ciphertext itself), so you can see WHICH column and Effect.catchTag it server-side. If it reaches the wire UNCAUGHT, it collapses to a generic InternalError for the client (the table.column and cause stay in the server log) — never an ExitEncoded schema 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 (enable fieldEncryption), or if fieldEncryption is 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=null

Now 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 unchanged

decryptField 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).