Skip to content

Data protection

Runs carry sensitive content by nature: prompts quote users, tool results carry records, transcripts hold whole conversations. This page is the policy toolkit for the two places that content can leave the process, persistence and telemetry, plus the compliance surfaces around them (export, deletion, audit). The gate all of it serves: PII never persists or emits in plaintext under policy.

The division of labor is deliberate:

BoundaryToolWhy
Persistence (journal, transcripts)Envelope encryption over the serialization hookLossless and reversible: replay, content keys, and the folds need the original bytes back. Lossy redaction of the journal would corrupt determinism, so it is a deliberate host trade, never a default.
Telemetry (events, traces)Redaction patterns on the masking policyTelemetry is lossy by design and leaves your trust boundary first. Masking there cannot perturb replay: events are excluded from identity by construction.

Envelope encryption

createEnvelopeEncryption puts real cryptography on the existing serialization seam, KMS-shaped:

ts
import { createEngine, createEnvelopeEncryption, localKeyProvider } from '@rulvar/core';
import { SqliteStore } from '@rulvar/store-sqlite';
import { anthropic } from '@rulvar/anthropic';

const enc = await createEnvelopeEncryption({
  provider: localKeyProvider({ secret: process.env.RULVAR_MASTER_SECRET ?? '' }),
});

const store = new SqliteStore({ path: './runs.db' });
const engine = createEngine({
  adapters: [anthropic()],
  stores: { journal: store, transcripts: store.transcripts() },
  serialization: enc.hook,
});

Under the hook, every persisted byte a run produces is AES-256-GCM ciphertext: journal payloads, transcript blobs, checkpoints. Grep the raw store and the PII is gone; read through engine.stores and you get plaintext, because the wrapped stores are the one policy point every reader passes. Resume, replay, recovery, and the CLI all read through the engine, so they never notice the encryption at all.

The mechanics, exactly as cloud KMS services frame the envelope pattern:

  • DataKeyProvider is the KMS seam. Its two methods are the shape of KMS GenerateDataKey and Decrypt; both are called only inside createEnvelopeEncryption, never per entry, so the synchronous hook runs on in-memory data keys and the read path needs no live KMS. An AWS provider is a direct mapping:
ts
import type { DataKeyProvider } from '@rulvar/core';

function kmsKeyProvider(kms: {
  generateDataKey(input: { KeyId: string; KeySpec: string }): Promise<{ Plaintext?: Uint8Array; CiphertextBlob?: Uint8Array }>;
  decrypt(input: { CiphertextBlob: Uint8Array }): Promise<{ Plaintext?: Uint8Array }>;
}, keyArn: string): DataKeyProvider {
  return {
    keyId: keyArn,
    async generateDataKey() {
      const out = await kms.generateDataKey({ KeyId: keyArn, KeySpec: 'AES_256' });
      return { plaintext: out.Plaintext ?? new Uint8Array(), wrapped: out.CiphertextBlob ?? new Uint8Array() };
    },
    async unwrapDataKey(wrapped) {
      const out = await kms.decrypt({ CiphertextBlob: wrapped });
      return out.Plaintext ?? new Uint8Array();
    },
  };
}
  • Tenant-scoped keys are providers: localKeyProvider({ secret, info: tenantId, keyId: local:${tenantId} }) partitions one master secret into unrelated key-encryption keys per tenant (a provider with different info cannot unwrap another tenant's keys, and the tests pin that), and with real KMS you pass a per-tenant key ARN. One engine per tenant, one provider per engine, exactly like the quota limiter's tenant dimension.
  • Every envelope carries its wrapped data key, so nothing but the provider registration is needed to read old data. Rotation is operational, not cryptographic bookkeeping: new sessions mint fresh data keys; readers of older history pass those sessions' wrapped keys as historicalWrappedKeys (each is unwrapped once at creation). An envelope carrying an unregistered key fails typed, naming the fix.
  • Full identity is associated data. A journal ciphertext authenticates over the run it belongs to and every immutable clear field of its entry (runId, hashVersion, seq, ref, scope, key, ordinal, kind, status); a blob ciphertext authenticates over its ref, which itself embeds the runId. A ciphertext transplanted into another run, moved between entries or refs, or read against an entry whose clear identity was rewritten on disk fails authentication instead of decrypting into the wrong place. This is the v2 envelope schema; pre-upgrade v1 journal envelopes (which bound only seq and key) still decrypt on read, and writes always emit v2, so an encrypting store upgrades in place with no migration step.
  • What stays plaintext: the kernel ordering and identity fields the hook contract pins (seq, key, kind, status, and friends), plus spanId and the timestamps, because stores index them and operators read them; none carry payload content. Run meta (name, tags, status) is deliberately not hooked either: it is the queryable operational index, so keep PII out of run names and tags, and see the salted digest for the derived-metadata leak.
  • Reads of non-enveloped data fail closed by default. A store with pre-encryption history reads through plaintextReads: 'passthrough' as an explicit migration mode; to converge, export and re-import the old runs through an encrypting engine.

Redaction patterns at the telemetry boundary

Events already mask credential-shaped strings by default (redaction.maskEvents, on since M8). RV-217 adds the host policy on top: your own PII patterns, compiled once, applied to every string in every emitted event body. One string deliberately stays outside the policy: the runId is a correlation key and rides every event envelope unmasked, so engine.run refuses typed a runId the active policy would rewrite (a secret-shaped id is a masking-bypass channel the host would be creating itself), alongside a 200-character length ceiling (RV1012); with maskEvents: false nothing is masked anywhere and the check does not apply.

ts
const engine = createEngine({
  adapters: [anthropic()],
  redaction: {
    patterns: [
      /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, // emails
      /\b\d{3}-\d{2}-\d{4}\b/,                          // SSN-shaped
    ],
  },
});

An invalid pattern is a typed ConfigError at createEngine, before anything runs under the policy. Feed the same list to the OTel exporter for trace parity (toOtel(run, tracer, { patterns })), which applies it to every exported string attribute on top of the default set. The journal is never redacted: masking is for the lossy telemetry boundary, encryption for the lossless persistence one.

Portable export and import

engine.exportRun(runId) produces the whole run as one portable bundle (the meta record, every journal entry, every transcript blob), read through engine.stores, so an encrypted deployment exports plaintext: a subject-access request is one call, not raw store spelunking. engine.importRun(bundle) writes the bundle through the target engine's stores, so an encrypting target re-encrypts under its own policy; together they are the store migration and key-rotation-by-rewrite path. Imports keep the original runId (transcript refs embed it), refuse typed when the run already exists, and a migrated run resumes on the target with zero live calls. The intake fails closed before the first write (RV1010): the bundle's runId passes the same assertSafeRunId guard engine.run and engine.resume apply, so an import can no more claim .., a slashed path, or an over-length id than a live run can (RV1206); every blob ref must live in the bundle runId's own namespace (<runId>/...), so a crafted bundle for run A can never overwrite run B's blobs; and every entry must pass the journal codec's shape validation, so an import never appends garbage it would later refuse to replay. Writes land blobs, then entries, then meta, and a mid-import store failure rolls the partial import back best-effort, so the exists-refusal never bricks the retry. Deletion was already first-class: engine.deleteRun cascades blobs then journal, and engine.pruneRun trims ok-terminal checkpoints; retention policy lives with the host, and the queue worker drives both under leases. One deliberate survivor: a leasable store keeps the run's fencing-epoch high-water mark as a tombstone through deleteRun (the runId and a counter, never run content), so a recreated runId still fences out zombie leases from the deleted incarnation. A deployment whose erasure duty covers run identifiers as such removes the store's epoch rows (or the whole store) as its own final step, after the fencing concern has lapsed.

Salted metadata digests

RunMeta.argsHash binds resumes to genesis args. It is deliberately deterministic, which also makes it correlating: equal args produce equal digests across unrelated deployments, and low-entropy args (a flag, a role, a short id) are recoverable by hashing candidates. security.argsHashSalt switches the digest to HMAC-SHA256 under a deployment salt:

ts
const engine = createEngine({
  adapters: [anthropic()],
  security: { argsHashSalt: process.env.RULVAR_ARGS_SALT ?? '' },
});

Within the deployment nothing changes (the resume args gate still verifies); across deployments the correlation breaks. The salt is deployment config: every engine and CLI host config resuming the same store must carry the same value, and runs recorded before the salt keep their unsalted digests (the gate then mismatches until forced, so introduce the salt on a fresh store or accept --allow-args-change on legacy runs). The CLI picks the salt up from engineOptions.security automatically.

The audit trail

The journal has always been the audit log; reduceAuditTrail(entries) is its first-class reader: a pure fold of one run's entries into the reviewable sequence of authority events, in order. Approvals and external suspensions (with deadlines), who resolved them and how (by: 'external' | 'operator' | 'timeout' | ..., with the resolved value), abandons with their authorizing decision and reason, engine decisions (escalation verdicts, acceptance, admission, fallbacks), termination denials, and every run settle.

ts
import { reduceAuditTrail } from '@rulvar/core';

const trail = reduceAuditTrail(await engine.stores.journal.load(runId));
for (const record of trail) {
  console.log(record.seq, record.at, record.category, record.summary);
}

Feed it entries read through engine.stores (or exportRun(runId).entries), so an encrypted deployment audits plaintext through the one policy point. The reducer is tolerant across journal vintages: unknown kinds and malformed payloads are skipped, never thrown on.

The deployment boundary

Rulvar is a library inside YOUR process, not a hardened perimeter, and the honest boundary line matters more than any single feature on either side of it. What the engine ENFORCES is what the journal can prove: the permission chain's verdicts, admission and budget ceilings, the settlement and quota ledgers, fencing of stale segments, tool identity through toolsetHash, and the approval and deadline resolutions above. What it ADVISES it says so in place: domain rules surface in the audit and change no verdict, redaction patterns protect telemetry but not a host that logs raw values itself, and the exploration guards bound waste, not malice. Everything else is deliberately OUTSIDE, and a production deployment should place it there on purpose:

  • Identity and authorization (IAM). The engine never authenticates a caller: whoever can reach resolveExternal, the CLI server routes, or the stores holds that authority. Front them with your identity layer, and journal the approver's identity in the resolution VALUE where the audit needs a principal; ResolutionBy records the channel, never a verified identity (tools).
  • Key management (KMS). The envelope encryption hooks accept whatever key provider you wire; rotation, escrow, and access policy for those keys belong to the KMS that owns them, not to the library that calls it.
  • Egress control (DLP). The no-content telemetry policy and the redaction patterns bound what the ENGINE emits; exports, artifacts, and tool side effects leave through your process, so route exportRun output and tool egress through the same DLP posture as any other service of yours.
  • Case management. The journal is the durable record of one run; incidents span runs, people, and time. Ship reduceAuditTrail output into the case store your responders already use instead of treating journals as the case system.
  • PII canaries. Seed synthetic identifiers (a canary email, a canary account number) into fixtures and watch for them at the boundaries you actually fear: the telemetry stream, exports, provider requests. A canary that surfaces where it must not is a boundary regression the type system cannot catch; the redaction patterns are where an engine-side hole would be, and your DLP is where a host-side one would.

The at-least-once tool window, the advisory nature of domain rules, and the unauthenticated resolution channel are documented non-guarantees (see SECURITY.md); this section exists so a deployment treats them as inputs to its architecture instead of discovering them in review.

Next steps

  • Stores: the serialization hook contract the encryption rides on.
  • Durability: what resume and replay require of persisted bytes.
  • Observability: the event stream the redaction policy protects.