Story-3018

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.

Surface note. No route, no schema change, no migration, no user-visible control — and by design no user-visible effect at all, since the whole point is that the second copy never intrudes on the first. Proof lives in 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.