Skip to content
GitHub
Decisions

ADR-043: Spectral Agent conversation persistence — three-state privacy, framework-owned `langgraph` schema, forward-trigger encryption

Status: Accepted (2026-04-21)

Context

This ADR is a carry-forward confirmation of the v0.2 Spectral Agent conversation persistence pattern (three-state privacy: customer-initiated private / broadcast template / forked-on-reply) against the 0.3.0 three-context architecture (ADR-032 storage topology), connection pooling (ADR-041), data retention (ADR-042), observability (ADR-036), secrets management (ADR-037), and OAuth (ADR-039). Complements ADR-007 (LangGraph supervisor + specialist) and ADR-017 (event-driven signal path); does not supersede either.

The disposition surfaced one architectural question worth real examination — LangGraph checkpointer schema placement (dedicated langgraph schema versus the domain platform schema versus platform + RLS) — and one proportionality recalibration: an initial draft expanded to 24 decisions with day-1 KMS-backed envelope encryption. A founder-direction recalibration mid-review identified that a carry-forward confirmation spike should not grow into day-1 enterprise-encryption work at alpha scope; envelope encryption was reserved as a named forward trigger per D10. The recut produced 11 decisions.

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; workspace session-var RLS is the backstop

Consistent with ADR-033 D1 (“app-layer tenancy primary, RLS backstop”). Within-workspace user-level conversation visibility is a repository-layer concern, enforced by AgentConversationRepository.get_conversation(conv_id, workspace_id, user_id) pre-check. (Note: ADR-059 D6 later added SESSION_VAR_USER_ID for the Ops Agent’s user-keyed RLS use case; for the Spectral Agent’s chat-conversation surface, app-layer remains primary.)

D6 — Retention posture

deleted_at timestamptz NULL on every workspace-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 (workspace-scoped, user_id IS NULL) tombstone only on workspace 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

V0.2 pattern carry-forward. 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.aput runs on the request-scope connection from spectral_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 {workspace_id, user_id, conversation_id, thread_id, op}.
  • Retention cascade: langgraph.* thread deletion is driven by conversations TOMBSTONED grace expiry via AsyncPostgresSaver.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-workspace DEK generated/wrapped via KMS (ADR-037 D12 reservation), DEK caching with TTL, KeyManagementProvider protocol as provider-swap seam (ADR-037 D11). ~2 engineer-weeks + KMS IAM + 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 (workspace-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 (the original deferral). Initially rejected per D5 here at TA-14 disposition time. The constant was later added by ADR-059 D6 for a different (Ops Agent persistent-tier RLS) use case — a use case that genuinely required user-keyed RLS. The Spectral Agent’s chat surface continues to enforce user-level visibility at the app layer per D5.

Per-message content_class column on conversation_messages. Rejected as YAGNI at alpha. All Spectral Agent conversation content is PLATFORM class by definition; SYNTHETIC comes from test agents via a different code path.

Day-1 KMS-backed envelope encryption. Reconsidered mid-review. Runtime cost negligible (~$0.50/month) but ~2 engineer-weeks of build with provider-swap protocol + DEK cache + rotation runbook. Recalibrated per founder direction: a carry-forward confirmation spike should not grow into day-1 enterprise-encryption work at alpha scope. 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.core additions).
  • Zero new migration now. The conversation table family lands with the platform context’s first workspace-scoped migration (implementation epic, not TA-review scope).
  • tools/quality/check_migration_naming.py annotates langgraph as a framework-owned schema (commit ba0d60b).
  • LangGraph AsyncPostgresSaver configured with schema_name="langgraph"; table setup via checkpointer.setup() at worker / API startup.
  • spectral_platform_app role provisioning step adds langgraph.* 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.mdx picks up path rewrites (packages/spectral/...src/spectral/platform/...), platform-schema placement note, and core.users FK note in close-pass.
  • Codex system-design/agents/agent-architecture.mdx picks up code-map path updates and the checkpointer schema note in close-pass.
  • ADR-007 complemented, not superseded. ADR-007’s status line gains an addendum noting that ADR-043 carries the 0.3.0 three-context deltas and the checkpointer schema posture.

References

  • ADR-007 — addendum: ADR-043 complements with 0.3.0 three-context deltas + checkpointer schema
  • ADR-017 — event-driven signal path (Spectral Agent portion stands)
  • ADR-065spectral.core admission 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 — KMS reservation for D10 envelope encryption
  • ADR-039core.users mirror (D4); session-var convention (D13)
  • ADR-041request_scope context manager
  • ADR-042 — retention state machine + cascade (D6)
  • ADR-044 — TA-5 pinning of trigger_event_id and notify mechanism (D11)
  • ADR-059 — separate use case that introduced SESSION_VAR_USER_ID (D5 framing context)
  • 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