ADR-043: Agent conversation persistence — three-state privacy, framework-owned `langgraph` schema, forward-trigger encryption
Context
This ADR defines the agent conversation-persistence substrate: a three-state privacy model (customer-initiated private / broadcast template / forked-on-reply) integrated with the three-context architecture (ADR-032 storage topology), connection pooling (ADR-041), data retention (ADR-042), observability (ADR-036), and secrets management (ADR-037). The substrate backs the World Agent’s customer-facing sessions (ADR-081 D2). It builds on ADR-007 (LangGraph agent architecture); the event-driven signal path it integrates with is the ADR-044 event substrate.
The load-bearing architectural question is LangGraph checkpointer schema placement (dedicated langgraph schema versus the domain platform schema versus platform + RLS). Envelope encryption is reserved as a named forward trigger (D10), not alpha work — the same scope-discipline posture as ADR-040 PITR and ADR-042 enforcement.
Decision
D1 — Three-state conversation privacy model confirmed
Customer-initiated private / broadcast template / forked-on-reply. The state-combination constraint table, fork-on-reply flow, and partial unique index on (forked_from, user_id) all carry over from v0.2 unchanged. Codex system-design/agents/agent-chat-privacy.mdx is the canonical pattern.
D2 — All conversation tables live in the platform schema
Owned by spectral_platform_app role (per ADR-041 D5). No refs between contexts; this is platform-internal.
D3 — Conversation contract types live in spectral.platform.conversation, not spectral.core
Platform-internal domain; no signal between contexts carried by conversation types. ADR-065 admission not triggered.
D4 — conversations.user_id FK → core.users(id) mirror
Per ADR-039 D4b; not auth.users. Single source of truth for user identity.
D5 — User-level visibility enforced at app layer; domain session-var RLS is the backstop
Consistent with ADR-033 D1 (“app-layer tenancy primary, RLS backstop”). Within-domain user-level conversation visibility is a repository-layer concern, enforced by AgentConversationRepository.get_conversation(conv_id, domain_id, user_id) pre-check. (Note: SESSION_VAR_USER_ID exists for genuine per-user-keyed RLS use cases per ADR-101 D3; the chat-conversation surface keeps app-layer visibility as primary.)
D6 — Retention posture
deleted_at timestamptz NULL on every domain-scoped conversation table (ADR-042 D3, prospective). Alpha default (conversation, PLATFORM) → DEFAULT_POLICY (365 d / 30 d / HARD_DELETE). conversation_messages cascade via TOMBSTONED parent (ADR-042 D8). Broadcast templates (domain-scoped, user_id IS NULL) tombstone only on domain deletion, not user deletion. Forked branches cited by applied change sets become REFERENCED (ADR-042 D6).
D7 — LangGraph checkpointer lives in a dedicated langgraph schema
Framework-owned DDL via AsyncPostgresSaver.setup(); the Supabase CLI migration pipeline does not touch it — analogous posture to Supabase’s auth.* / storage.* / realtime.* schemas. tools/quality/check_migration_naming.py annotates langgraph as a framework-owned excluded schema (commit ba0d60b).
D8 — Checkpointer security posture (alpha)
No RLS on langgraph.* (framework-owned, keyed by thread_id). Role-scoped: spectral_platform_app gets USAGE + SELECT/INSERT/UPDATE/DELETE on langgraph.*; no TRUNCATE, no DDL, no ownership. All AsyncPostgresSaver calls flow through a single repository gate (spectral.platform.agent.CheckpointerGateway); a post-alpha ast-grep lint enforces the single-path rule.
D9 — Checkpointer operational posture
- Same-transaction participation:
AsyncPostgresSaver.aputruns on the request-scope connection fromspectral_platform.db.request_scope(per ADR-041 D8), avoiding torn-write risk between business ops and checkpoint writes. - Every checkpointer op emits a structlog entry through the ADR-036 D5 canonical-fields stream with
{domain_id, user_id, conversation_id, thread_id, op}. - Retention cascade:
langgraph.*thread deletion is driven byconversationsTOMBSTONED grace expiry viaAsyncPostgresSaver.adelete_thread(X)— one retention source of truth.
D10 — Envelope encryption reserved as forward trigger, not alpha work
Named triggers:
- First design-partner contract with Safeguards flow-down
- First enterprise prospect requiring encryption-at-rest beyond disk-level
- First non-US partner bringing GDPR Art. 32 into scope
- First regulatory regime applicable to a partner domain requiring encryption at rest
Implementation shape: EncryptedSerializer wrapping AsyncPostgresSaver’s SerializerProtocol, per-domain DEK generated/wrapped through the Supabase key-management substrate (Vault / pgsodium — no external KMS, ADR-037 D12), DEK caching with TTL, KeyManagementProvider protocol as provider-swap seam (ADR-037 D11). ~2 engineer-weeks + key-provisioning + rotation runbook when a trigger fires.
D11 — trigger_event_id and AgentTask notify mechanism deferred to event-substrate ADR
conversations.trigger_event_id column is nullable pending the event-substrate decision; under ADR-044 it becomes the outbox row ID (per ADR-044 D12). AgentTask persistence shape confirmed (domain-scoped, deleted_at, retention-inherits); the notify mechanism is pinned by ADR-044.
Alternatives considered
LangGraph checkpointer in the domain platform schema. Rejected. Creates a migration-ownership conflict (LangGraph DDL via checkpointer.setup() versus the Supabase CLI migration pipeline); the framework-owned pattern is cleaner.
LangGraph checkpointer in platform schema with RLS. Rejected. AsyncPostgresSaver internal queries are not designed to cooperate with session-var RLS; framework-owned posture with role-scoping is the DBA-idiomatic answer.
SESSION_VAR_USER_ID in spectral.core.db.session_vars for chat conversations. Rejected per D5: within-domain user-level conversation visibility is a repository-layer concern, not a user-keyed RLS case. The constant exists for genuine per-user-keyed RLS elsewhere (ADR-101 D3); the chat surface enforces user-level visibility at the app layer.
Per-message content_class column on conversation_messages. Rejected as YAGNI at alpha. All agent conversation content is PLATFORM class by definition; SYNTHETIC comes from test agents via a different code path.
Day-1 envelope encryption (DEK-per-domain). Rejected at alpha scope. Runtime cost negligible (~$0.50/month) but ~2 engineer-weeks of build with provider-swap protocol + DEK cache + rotation runbook. Reserved as a named forward trigger per D10 — the same pattern as ADR-040 PITR, ADR-042 enforcement, ADR-037 D12.
Checkpointer in a separate transaction. Rejected. Classic torn-write risk. Same-transaction via the request-scope connection is correct.
Consequences
- Zero new contract surface between contexts. Conversation types are platform-internal (no
spectral.coreadditions). - Zero new migration now. The conversation table family lands with the platform context’s first domain-scoped migration (implementation epic, not TA-review scope).
tools/quality/check_migration_naming.pyannotateslanggraphas a framework-owned schema (commitba0d60b).- LangGraph
AsyncPostgresSaverconfigured withschema_name="langgraph"; table setup viacheckpointer.setup()at worker / API startup. spectral_platform_approle provisioning step addslanggraph.*CRUD grants when platform context provisioning lands.- Retention-job worker dependency: must call
AsyncPostgresSaver.adelete_thread(X)for each TOMBSTONED conversation past grace (D9). - Checkpointer access discipline: post-alpha ast-grep lint enforces the single-gate pattern (D8).
- Alpha tenancy story for the checkpointer is app-layer repository gate → role-scoped DB access → audit logs → retention cascade. No RLS on
langgraph.*, no encryption-at-rest beyond disk-level. Defensible at US-only NDA alpha scope; D10 triggers take over before any partner contract with Safeguards flow-down. - Codex
system-design/agents/agent-chat-privacy.mdxpicks up path rewrites (packages/spectral/...→src/spectral/platform/...),platform-schema placement note, andcore.usersFK note in close-pass. - Codex
system-design/agents/agent-architecture.mdxpicks up code-map path updates and the checkpointer schema note in close-pass. - Builds on ADR-007. ADR-043 adds the three-context placement and the checkpointer schema posture on top of ADR-007’s agent architecture.
References
- ADR-007 — agent architecture; ADR-043 adds three-context placement + checkpointer schema
- ADR-065 —
spectral.coreadmission discipline (no surface added here) - ADR-031 — single-library + per-context schema location
- ADR-032 — schema topology
- ADR-033 — app-layer primary; RLS backstop
- ADR-036 — structlog canonical fields (D9)
- ADR-037 — Supabase-substrate key management (no external KMS) for D10 envelope encryption
- ADR-039 —
core.usersmirror (D4); session-var convention (D13) - ADR-041 —
request_scopecontext manager - ADR-042 — retention state machine + cascade (D6)
- ADR-044 — TA-5 pinning of
trigger_event_idand notify mechanism (D11) - ADR-101 D3 —
SESSION_VAR_USER_IDidentity contract - TA-14 disposition — SPEC-317 comment
2fc2b762 - TA-14 verification — SPEC-317 comment
ad229650 - Codex
system-design/agents/agent-chat-privacy.mdx— close-pass path updates - Codex
system-design/agents/agent-architecture.mdx— close-pass code-map updates