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 withreencrypt(). - 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
actorset on the thread (user:dr-rao). - Records are chained by SHA-256:
verifyreports the first changed or missing record, and the database refuses plainUPDATE,DELETEandTRUNCATEon 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 lateraudit.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.