Encryption, audit trail, retention

Three storage features for software that handles personal or medical data. Each is one setting.

from criogenai import Storage

storage = Storage(
    "postgres://app:secret@db.internal/agents",
    encryption_keys=os.environ["CRIOGEN_ENCRYPTION_KEYS"],  # "k1:<64 hex digits>"
    audit=True,
    retention={"thread_ttl_secs": 30 * 86_400},
)

await storage.prune()  # apply retention (e.g. from a cron job)

# Who did what on a thread (no message text, arguments or results).
records = await storage.audit_records(thread_id="visit-7f3a")
report = await storage.verify_audit()  # checks the hash chain
assert report["ok"]
import { Storage } from "criogenai";

const storage = new Storage({
  url: "postgres://app:secret@db.internal/agents",
  encryptionKeys: process.env.CRIOGEN_ENCRYPTION_KEYS, // "k1:<64 hex digits>"
  audit: true,
  retention: { threadTtlSecs: 30 * 86_400 },
});

await storage.prune(); // apply retention (e.g. from a cron job)

// Who did what on a thread (no message text, arguments or results).
const records = await storage.auditRecords({ threadId: "visit-7f3a" });
const report = await storage.verifyAudit(); // checks the hash chain
if (!report.ok) throw new Error(`audit chain broken at ${report.first_broken}`);
use std::time::Duration;

use criogenai_storage::{AuditQuery, Keyring, Retention, Storage};

// CRIOGEN_ENCRYPTION_KEYS="k1:<64 hex digits>"  (openssl rand -hex 32)
let keys = Keyring::from_env("CRIOGEN_ENCRYPTION_KEYS")?;
let storage = Storage::connect_encrypted("postgres://app:secret@db.internal/agents", keys)
    .await?
    .with_audit()
    .retention(Retention::new().thread_ttl(Duration::from_secs(30 * 86_400)));

// Delete what retention says, once an hour.
let pruner = storage.spawn_pruner(Duration::from_secs(3_600));

// Who did what on a thread (no message text, arguments or results).
let audit = storage.audit().expect("audit is on");
let records = audit.query(&AuditQuery::new().thread("visit-7f3a")).await?;
let report = audit.verify().await?; // checks the hash chain
assert!(report.ok);

// Key rotation: put the new key first ("k2:…,k1:…"), then
storage.reencrypt().await?;

Encryption at rest#

With keys, everything with content is encrypted in the database: messages, approval requests, tool results, remembered facts and their vectors (AES-256-GCM, a key per row derived from yours). Memory keys are stored as keyed hashes.

  • No accidental plain text. Once a database is encrypted, opening it without its keys fails (encryption_key_missing), so an app started without keys cannot write plain text into it.
  • No planted plain text. With keys, a content row that is not encrypted is refused (storage_corrupt) instead of read: someone who can write to the database file cannot slip in a forged checkpoint, approval decision or memory fact. A database written before encryption was on must be converted first with reencrypt().
  • Key rotation. Put the new key first (k2:…,k1:…): new data uses it, old data still reads. reencrypt() rewrites old data with the new key.
  • What stays readable: thread ids, memory namespaces, timestamps. Use opaque ids.

Generate a key with openssl rand -hex 32 and keep it in a secrets manager, never in code or the repository.

Audit trail#

Records who did what, when: runs, model calls (model, tokens), tool calls, approval requests and decisions (recorded before the call runs), rewinds, deletions and memory writes (the key only as a hash). Records hold no content: no message text, no tool arguments or results.

  • Each record names the actor set on the thread (user:dr-rao).
  • Records are chained by SHA-256: verify reports the first changed or missing record, and the database refuses plain UPDATE, DELETE and TRUNCATE on the table (triggers).
  • Someone with schema rights can drop the triggers and recompute the whole chain, which no hash chain can show by itself. Sign the head with a key kept outside the database (audit.anchor(key)), store the anchor where the database's writers cannot reach, and later audit.verify_anchor(&anchor, key): it fails if the chain was cut short or recomputed.
  • Fail closed: if a record cannot be written, the action fails (audit_failed) rather than happening unrecorded.

Retention#

Rules apply when prune() runs (from a scheduled job), or in the background (spawn_pruner, Rust): delete threads idle longer than a time, keep only the last N checkpoints, drop rewound branches; expired memory goes too. forget_all deletes every fact about one person.