Every Mira turn is archived to S3 as the firm’s own record
Until this change a matter’s conversation existed in exactly one place:
rows in agents.chat_turn. The schema has always allowed
content_storage='s3', and the code said so out loud — a
ponytail: comment in runner.py noted the column had
no writer anywhere in this repo. So there was no durable record of a
matter’s conversation outside the database, and nothing a firm could be
handed on the way out.
Now every settled turn is also written to S3, in the firm’s own prefix, held exactly as its documents are held. This is the second copy. Postgres remains the first.
The key — pramaan-docs ADR-126 D2, not invented here
firms/{firm_id}/matters/{matter_id}/conversations/{counsel_id}/{agent_key}/{turn:06d}.json
| Segment | Why it is where it is |
|---|---|
firms/ outermost |
The ADR-126 D5 isolation boundary. It is what makes a firm export one
command —
aws s3 sync s3://…/firms/{id}/ ./export/chats/ —
and what keeps case-by-case operator erasure tractable if a firm ever asks
for it: a firm’s entire conversational record is a single prefix.
|
conversations/ beside documents/ |
A conversation is a matter-scoped non-document asset —
exactly the class D2 provides for, which already names
agent_runs/ as the precedent. It is a sibling of documents,
not a child of them.
|
{turn:06d} |
Load-bearing, not cosmetic. S3 orders keys lexicographically and always
ascending — there is no “newest first”. Unpadded, a LIST
returns 1, 10, 100, 2. The index is the one
_persist_converse_turns allocated off the session counter, so
the archive and the database name the same turn the same way.
|
The object
{ "turn": 41, "role": "counsel|mira", "text": "…",
"ts": "2026-07-27T09:14:22Z", "run_id": "…", "session_id": "…",
"model": "deepseek-v4-flash", "input_tokens": 52104,
"cached_tokens": 50000, "output_tokens": 480, "cost_usd": 0.00039 }
The usage tail costs nothing to write and is what lets the archive answer
questions later: how far back a reference actually reached, what a real session
costs, how often a tool fires. It comes from the run’s
_RunTotals — the same numbers the terminal
agent_run row carries, so the archive and the run row can never
disagree about what a turn cost. cache_read_tokens is newly
accumulated onto those totals for this purpose. An unreported value stays
null; nothing is fabricated as zero.
role is the firm’s vocabulary, not the model’s:
user/assistant in the database becomes
counsel/mira in the record a firm reads.
Where it fires — last, always
turn.completed pushed → thread rows durable → render frame → ARCHIVE
The call sits at the very end of the terminal branch in
_consume_stream, after _emit_render_frame, and at the
matching seam on the remember: ack path. Two consequences, both
deliberate:
| Property | Why |
|---|---|
| After delivery |
A slow S3 write costs the lawyer nothing. The answer has already streamed
and settled before the archive is even attempted. The blocking boto3 PUT
then rides asyncio.to_thread, off the worker’s loop.
|
| Fail-open, non-negotiable |
Every failure path inside archive_conversation_turns logs and
returns; nothing raises at the caller. A missing bucket (its twin,
Chore-3017, lands separately), a missing IAM grant, an S3 outage, even a
missing boto3 — none of it reaches counsel, because the
turn is already durable in Postgres. This is deliberately not an
R5 honest-terminal-state path: there is no user-visible pipeline to stamp,
only a second copy that is either there or is not.
|
The failure is loud where it should be —
clerk.conversation_archive_failed carries the bucket, the run, and
how many of the expected objects actually landed — so a silently
un-archiving fleet shows up as a signal rather than as silence.
What was deliberately NOT built
| Not built | Reason |
|---|---|
| Any delete path |
PRAMAAN has no hard delete. ‘deleted’ is a state (ACP lifecycle
0149), rows are retained, and the domain AGENTS.md files forbid
physical deletion outright. A soft-deleted matter keeps its conversations
exactly as it already keeps its documents. A test walks all of
src/ and fails if an S3 delete call ever appears — it has
been observed red (it caught the literal in this module’s own
docstring before the check was narrowed to calls).
|
| A reader | Nothing consumes the archive yet. Out of scope, by the ticket. |
The content_storage='s3' overflow path |
A different job: that is about oversized turn bodies; this is about
keeping every turn. The comment in _persist_converse_turns
still stands.
|
| Retention / lifecycle rules | A product decision, not an infra default. |
| S3 Versioning | Turn 41 is turn 41 forever. One new key per turn; versioning an object that never changes buys nothing (Chore-3017 disables it explicitly). |
Config, never a literal
CONVERSATION_ARCHIVE_BUCKET_NAME, resolving through
Settings.resolved_conversation_archive_bucket_name. Unset, it
derives the canonical Chore-3017 spelling
(pramaan-conversations-<env>-<region>) the same way
agent_runs_bucket_name does — so the writer always has a name
to fail open against, and no bucket string is ever hard-coded in the writer.
tests/unit/clerk_worker/test_conversation_archive.py: key shape
and padding, ordering (every PUT observed the thread row already inserted and
the answer already pushed), the usage tail on every object, a raising client and
an unbuildable client both leaving the turn succeeded, and the repo-wide
no-delete check. Each of those was planted-negative proven red before it counted
green.