Store conversations and protect traces
In short
- To make the reply readable,
piighostkeeps, for each conversation, the sensitive values found in each message. This storage therefore contains personal data. - The three storage locations are the program memory (lost on restart), Redis and a SQL database.
- Redis and the SQL database can encrypt what they keep. Encryption requires two secrets supplied by the server's environment.
- Erasing a conversation deletes its storage. On a server with several processes, a temporary copy can survive in the other processes as long as no lifetime is set.
- Technical traces contain the clear text by default. A setting replaces it with placeholders.
This page is mainly for developers and operators. The flow of a conversation is described in Follow a conversation and restore the reply. The terms are defined in the glossary. Going to production is described in the technical guide. Deploy a pipeline in production covers one server, and Multi-instance deployment covers several instances behind a load balancer. The storage guarantees are detailed there in Security.
Choose a storage
| Storage | [memory] type key | Survives restart | Shared between processes | Lifetime |
|---|---|---|---|---|
| Program memory | in_memory | no | no | ttl per conversation, max_threads (10,000 conversations and one day by default) |
| Redis | redis | yes | yes | ttl per message |
| SQL database (SQLAlchemy) | sqlalchemy | yes | yes | none |
Recommendation: in_memory for development and tests, Redis or SQL as soon as several processes serve the same conversation.
What is stored
Each message is stored with its digest, that is a short string computed from its text, used to recognize the message without keeping that text. The storage also keeps the role of its author (user or assistant) and its detections (position, text, label, confidence). The text of the detections is the sensitive data.
- Redis:
{namespace}:{thread_id}:msg:{digest}holds the role and the detections.{namespace}:{thread_id}:indexholds the arrival order of the messages (conversation_memory/redis_backend.py:7-12). - SQL: one row per message in
piighost_conversation_messages(id,thread_id,message_digest,role,detections,detection_count).
Encrypting a storage relies on two components, always configured together:
- The hasher: it computes the digest of each message with a secret, the pepper (
PIIGHOST_HASH_PEPPER). Without the pepper, nobody can recompute the digest of a known text to check whether it was stored. - The cipher: it encrypts the stored detections with an AES key (
PIIGHOST_CIPHER_KEY). Without the key, the stored values are unreadable.
Rules to know
BR-STO-01 . When you supply a hasher without a cipher, or a cipher without a hasher, then the build fails. In code, the error is ValueError("Provide both a hasher and a cipher, or neither"). In configuration, it is ConfigError("Configure both a hasher and a cipher, or neither"). Hashing the keys while leaving the values in clear protects nothing. 2 locations · 2 direct tests
BR-STO-02 . When Redis or a SQL database is built without encryption, then a PIIGhostSecurityWarning is emitted. A SQLite database is the exception and does not trigger the warning (conversation_memory/sqlalchemy_backend.py:99-100). 3 locations · 12 direct tests
BR-STO-03 . When encryption is active, then the conversation identifier stays in clear. It serves as the Redis key prefix and as a SQL column, so that a conversation can be listed and erased. Do not put personal data in it (an e-mail address, a name). 2 locations · 12 direct tests
BR-STO-04 . When the program memory is created without settings, then it keeps at most 10,000 conversations. Beyond that, the least recently used one is evicted. Each conversation expires one day (86,400 seconds) after its last write, and it is dropped at the next access. max_threads and ttl change these bounds. For example, a conversation written on October 2, 2026 at 9:00 and not touched afterwards is forgotten from October 3, 2026 at 9:00. 4 locations · 37 direct tests
BR-STO-05 . When Redis has a ttl, then each message expires this number of seconds after its write. The conversation index receives the same lifetime at each new message. The SQL database has no expiration. Erase the conversations yourself. 1 location · 2 direct tests
BR-STO-06 . When a conversation is erased, then its storage and the placeholder cache of the process that receives the request are emptied. The other processes keep their cache until its eviction (256 maps at most) or until token_memo_ttl. For example, on a server with 4 processes, an erasure request received by process 1 leaves the values in the cache of processes 2 to 4 as long as token_memo_ttl is not set. 2 locations · 8 direct tests
BR-STO-07 . When the encryption key is not 16, 24 or 32 bytes once decoded from base64, then the build fails with InvalidKeyLengthError. 1 location · 7 direct tests
BR-STO-08 . When no trace redactor is configured and an OpenTelemetry exporter is active, then the traces carry the clear text. A PIIGhostSecurityWarning is emitted at build time, unless trace_clear_text=True acknowledges it. 1 location
Configure encrypted Redis
- Install the extras:
uv add "piighost[redis,crypto,argon2,config]". - Export the pepper:
PIIGHOST_HASH_PEPPER(any non-empty string, kept out of the repository). - Export the key:
PIIGHOST_CIPHER_KEY="$(openssl rand -base64 32)". - Start from
examples/config/thread_redis.toml, which already declares[memory.hasher]and[memory.cipher]. The memory section has this shape:
[memory]
type = "redis"
url = "redis://localhost:6379/0"
ttl = 86400
[memory.hasher]
type = "argon2"
[memory.cipher]
type = "aesgcm"- Load the pipeline with
load_thread_pipeline("pipeline.toml").
For a SQL database, replace the section with type = "sqlalchemy", export the asynchronous URL in PIIGHOST_DATABASE_URL, then call await pipeline.memory.create_schema() once at startup. Loading the configuration does not create the table.
Check
redis-cli --scan --pattern 'piighost:*'You must see piighost:<thread_id>:msg:<digest> keys. redis-cli GET on one of them must return unreadable bytes, not a JSON containing the values.
Choose the hasher
| Hasher | type | Cost | Resists a pepper leak |
|---|---|---|---|
| HMAC-SHA256 | sha256 | fast | no |
| Argon2id | argon2 | slow, memory-hungry | yes, partly |
Argon2id takes by default time_cost = 2, memory_cost = 19456 KiB, parallelism = 1, hash_length = 32. The hasher runs at each message, so measure the latency before hardening these values.
Redact the traces
The pipeline opens one span per stage (piighost.detect, piighost.link, piighost.render, etc.) through OpenTelemetry. A span is a trace entry that measures one stage and carries its data. Without the observation extra, the tracer does nothing. With it, the spans go to the application's TracerProvider.
- Without a redactor, the text and the detected values are traced in clear. The traces then contain personal data. Keep this mode for a trace service you control, and acknowledge it with
trace_clear_text=True(BR-STO-08 ). - With
observation_redactor(a placeholder factory,[observation_redactor]section), the values are replaced with placeholders in the traces. - The conversation pipeline sets the conversation identifier in the
langfuse.session.idattribute, in clear, even with a redactor.
Pitfalls
- The placeholder cache is not shared between processes. Set
token_memo_ttlas soon as you erase conversations on a multi-process deployment (BR-STO-06 ). - The SQL table is not created by the configuration. Without
create_schema(), the first message fails. - Two simultaneous SQL writes of the same message can raise a uniqueness constraint error, because the check and then the write are not atomic (
sqlalchemy_backend.py:124-127). Redis, for its part, retries underWATCH. - The word pattern cache is shared by the whole process.
forget_threaddoes not empty it. Callclear_boundary_cacheif the erasure request covers the whole process. - A lost AES key makes the memory unreadable. The ongoing conversations can no longer be restored.
Where the rules live
| Rule | Location |
|---|---|
| BR-STO-01 | conversation_memory/base.py:59-71 (require_paired_crypto), config/models/memory.py:61 (the same rejection in configuration) |
| BR-STO-02 | conversation_memory/base.py:43 (warn_plaintext), called by conversation_memory/redis_backend.py:105 and conversation_memory/sqlalchemy_backend.py:99-100, which spares SQLite |
| BR-STO-03 | conversation_memory/redis_backend.py:107-113 (_index_key, keys prefixed by the conversation identifier), conversation_memory/sqlalchemy_backend.py:92 (thread_id column) |
| BR-STO-04 | conversation_memory/memory.py:45-62 (__init__, bounds DEFAULT_MAX_THREADS and DEFAULT_TTL at lines 13 and 20), _expired lines 140-144, _evict lines 150-157 |
| BR-STO-05 | conversation_memory/redis_backend.py:148-152 (remember, expiration of the message and of the index) |
| BR-STO-06 | pipeline/thread.py:244-262 (forget_thread), pipeline/thread.py:29 (_TOKEN_MEMO_MAX = 256) |
| BR-STO-07 | crypto/cipher/aesgcm.py:46-52 (AesGcmCipher.__init__) |
| BR-STO-08 | pipeline/base.py:190-202 (the warning of __init__, acknowledged by trace_clear_text) |
Tests
| Test | Covers |
|---|---|
tests/conversation_memory/ | Each storage: conversation isolation, erasure, lifetimes, clear-text warning (test_warn_plaintext.py) |
tests/conversation_memory/test_sqlalchemy.py | Table creation, encrypted storage in SQL |
tests/crypto/ | AES key length, empty pepper rejected, determinism of the hashers |
tests/observation/ | Emitted spans, redaction of their content, clear-text trace warning |
The Redis tests run against fakeredis (tests/conversation_memory/test_redis.py:21-27), not against a real server. test_concurrent_identical_remembers_do_not_duplicate checks atomicity under WATCH with this fake client. The behavior of a real Redis cluster is not covered.
See also Configure a pipeline for the secrets and the [memory] section.