OGuardAI
ArchitectureDecision Records

ADR 0004: Sealed Session State

Token mappings travel as an AES-256-GCM encrypted blob so the runtime is stateless per request

Status: Accepted

Context

A transform produces a token-to-value mapping, and the later rehydrate needs that mapping to restore values. The naive answer is a server-side session store. That forces sticky sessions or a shared datastore, complicates horizontal scaling, and is a non-starter for air-gapped deployment. But the mapping is the most sensitive object in the system: it is the dictionary from tokens back to raw PII. It cannot travel in the clear.

Decision

The default session backend is sealed: the session (including the token mapping) is encrypted into a blob that the client holds and passes back on rehydrate. The runtime keeps no per-request state.

  • AES-256-GCM. crates/session/src/sealed.rs seals the session with Aes256Gcm. A fresh random nonce is generated per seal.
  • Bound, not just encrypted. The tenant id and the audience are bound into the AEAD additional authenticated data, so a blob cannot be repurposed across tenants or across the three audiences (Standard client session, per-chunk RagChunk RAG ingest, and BackendStore encrypted server-side store). unseal_with_tenant fails closed on a tenant mismatch (PolicyDenied), and a blob with no tenant binding is only accepted in a no-tenant context.
  • Tamper-evident. If the GCM tag does not verify on unseal, the entire blob is rejected and fails closed.
  • Replay-protected. Sealed blobs carry a nonce and request counter for replay protection, backed by a request-replay store (in-memory per-replica by default, or a shared Redis store for durable cross-replica enforcement). A shared Redis replay store runs across replicas, so it also requires a shared Redis revocation store (revocation_backend=redis), otherwise a value revoked on one replica stays restorable through another and startup fails closed.

Other backends exist: an in-memory backend for dev and test, and a Redis session backend that stores each session encrypted at rest (shipped after this decision, for server-side sessions the client references by session_id). Sealed is the default because it needs no shared session store; revocation is still shared state a multi-replica deployment requires (revocation_backend=redis).

Consequences

  • Horizontal scaling is straightforward for the session path: any runtime instance can rehydrate any blob, because the state travels with the request rather than living on one node. A multi-replica deployment still shares one piece of state, the revocation set (revocation_backend=redis), so an erased value stays erased across every replica.
  • Air-gapped and self-hosted deployment is straightforward, since there is no session database to run.
  • The client becomes the custodian of the encrypted blob. It holds ciphertext only, and it cannot read or forge the contents without the server key, but it is responsible for passing the blob back.
  • Cross-tenant, tampered, and stale blobs fail closed rather than degrade, which is the correct posture for the object that maps tokens back to raw PII.