Skip to content

Store conversations and protect traces

In short

  • To make the reply readable, piighost keeps, 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 keySurvives restartShared between processesLifetime
Program memoryin_memorynonottl per conversation, max_threads (10,000 conversations and one day by default)
Redisredisyesyesttl per message
SQL database (SQLAlchemy)sqlalchemyyesyesnone

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}:index holds 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

  1. Install the extras: uv add "piighost[redis,crypto,argon2,config]".
  2. Export the pepper: PIIGHOST_HASH_PEPPER (any non-empty string, kept out of the repository).
  3. Export the key: PIIGHOST_CIPHER_KEY="$(openssl rand -base64 32)".
  4. 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"
  1. 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

HashertypeCostResists a pepper leak
HMAC-SHA256sha256fastno
Argon2idargon2slow, memory-hungryyes, 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.id attribute, in clear, even with a redactor.

Pitfalls

  • The placeholder cache is not shared between processes. Set token_memo_ttl as 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 under WATCH.
  • The word pattern cache is shared by the whole process. forget_thread does not empty it. Call clear_boundary_cache if 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

RuleLocation
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

TestCovers
tests/conversation_memory/Each storage: conversation isolation, erasure, lifetimes, clear-text warning (test_warn_plaintext.py)
tests/conversation_memory/test_sqlalchemy.pyTable 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.