ADR-0084: Dual-Plane Conversation Persistence¶
| Field | Value |
|---|---|
| Status | Proposed |
| Date | 2026-09-19 |
| Authors | Spector Maintainers & Architecture Working Group |
| Deciders | Spector Technical Steering Committee (TSC) |
| Supersedes | Operational interpretation of ADR-0006 for Synapse chat transcripts |
| Superseded By | None |
| Related | ADR-0006 (episodic conversation), ADR-0046 / ADR-0047 (engram model), ADR-0074 (reflect / sleep), ADR-0079 (memory event bus), Issue #263 |
| Last Verified | 2026-09-19 (Verified against main @ c06fee8) |
1. Context¶
Synapse chat today persists conversation state by writing raw turns into Spector Memory.
SpectorMemoryChatAdapter stores each user and assistant message as an engram tagged chat, session:{sessionId}, role:*, type:turn, and lists sessions via further engrams tagged session_summary / id:{sessionId}. AgentMemoryBridge additionally writes thoughts, tool arguments, and tool observations into WORKING and EPISODIC memory with session_{sessionId} tags.
That design collapses two different problems onto one store:
- Operational execution — replay this thread, show thinking and tool cards, resume an interrupted LangGraph4j turn, delete a chat, measure tokens.
- Cognitive memory — recall what is true about the user and the world across threads, with decay, provenance, and association.
ADR-0006 correctly models salient conversational episodes as off-heap episodic engrams with role and temporal structure. It does not say that every token, CoT scratchpad, and tool JSON dump is an engram. The Synapse adapter treated it that way anyway.
Issue #263 and the streaming-agentic-chat requirements make the collision load-bearing: the UI must replay thinking traces and tool executions, the graph must checkpoint mid-turn, and Spector Memory must not be poisoned by high-cardinality session tags or raw tool payloads.
flowchart LR
subgraph Now["Current — one plane"]
Chat[ChatService] --> Adapter[SpectorMemoryChatAdapter]
Bridge[AgentMemoryBridge] --> SM[Spector Memory]
Adapter --> SM
SM --> Tags["session:uuid<br/>type:turn<br/>raw tool JSON<br/>CoT text"]
SM --> Prime[ContextPrimingService]
Chat --> Prime
end 2. Problem Statement¶
Using Spector Memory as the chat database creates four architectural failures:
- Tag-index pollution. Session UUIDs are high-cardinality. The inverted tag index is built for low-cardinality synaptic labels (
preference,people,project).session:{uuid}explodes the tag dictionary and makesbrowse()a session table scan by another name. - Vector-space poisoning. Raw assistant transcripts,
<think>traces, and tool JSON are embedded. Recall then surfaces procedure noise (“Action: Called toolmemory_recallwith args …”) instead of facts. - Missing operational fidelity.
ChatMemoryPort.saveToSession(user, assistant, model)drops thinking traces, tool calls, tool outputs, token usage, and graph checkpoints.GET /sessions/{id}/messagescannot satisfy the new UI contract. - No interrupt/resume. LangGraph4j state is not checkpointed. A dropped SSE connection loses the turn. Memory engrams cannot hold compiled graph blobs.
Cognitive provenance already exists (MemorySource.USER_STATED | OBSERVED | INFERRED | REFLECTED) and is underused by the chat path. Reflection writes through memory_remember without a stable source or an allowlist.
3. Decision Drivers¶
- Separation of concerns: transcripts are logs; engrams are memories.
- Replay parity: live SSE events and historical turns share one durable event log.
- Memory hygiene: no high-cardinality identifiers in synaptic tags; no raw tool or CoT payloads in the vector space.
- Provenance discipline: every engram written from chat has an explicit
MemorySource. - Resumability: interrupted agent turns resume from a JDBC checkpoint, not from a reconstructed prompt guess.
- Store fitness: H2/JDBC is the right tool for CRUD sessions; Spector Memory is the right tool for fused recall.
- Continuity with ADR-0006 / ADR-0047: episodic episodes remain first-class in memory — they are distilled from the operational log, not identical to it.
- Compatibility: H2 for local/dev, ANSI SQL that can move to Postgres without a rewrite.
4. Considered Options¶
Option 1: Keep Spector Memory as the transcript store (status quo)¶
- Description: Add more tags (
type:thinking,type:tool) and browse them for replay. - Advantages: No new database; one “everything is memory” story.
- Disadvantages: Deepens tag pollution; tool JSON remains embedded; checkpoints still have no home; delete-session becomes
forget()across unbounded ids; contradicts the #263 dual-plane requirement.
Option 2: Dual-write every event to JDBC and Memory¶
- Description: Durable JDBC log plus an engram per event.
- Advantages: Replay and recall both “just work.”
- Disadvantages: Doubles write amplification; still poisons recall; session tags still pollute the index. The cognitive plane is not a backup of the operational plane.
Option 3: Dual-plane persistence with gated cognitive ingestion (selected)¶
- Description: JDBC is the system of record for sessions, events, and graph checkpoints. Spector Memory receives only salient episodes and distilled facts, tagged with domain vocabulary and formal provenance. Session identifiers never enter the tag index.
- Advantages: Fits each store; enables UI replay and graph resume; protects recall quality; aligns with ReflectPathway / ADR-0074.
- Disadvantages: Two write paths to reason about; migration off existing
session:engrams; Cortex must stop treating Memory browse as a session list.
Option 4: Operational plane only (no chat-driven remember)¶
- Description: JDBC for everything; priming uses only memories created by explicit user/
memory_remembertools. - Advantages: Simplest hygiene.
- Disadvantages: Throws away automatic episodic learning — the product differentiator. Rejected as the steady state; acceptable only as a temporary feature flag while the reflector is built.
5. Decision Outcome¶
Chosen Option: Option 3 — Dual-plane persistence with gated cognitive ingestion.
5.1 Plane definitions¶
flowchart TB
subgraph Op["Operational Execution Plane — Synapse JDBC"]
S[CHAT_SESSION]
T[CHAT_TURN]
E[CHAT_EVENT]
C[GRAPH_CHECKPOINT]
end
subgraph Cog["Cognitive Memory Plane — Spector Memory"]
WM[WORKING — optional scratch, no session tags]
EP[EPISODIC — salient episodes]
SEM[SEMANTIC — REFLECTED distillations]
PR[PROCEDURAL — tools/skills, unchanged]
end
User[User message] --> S
User --> T
Graph[LangGraph4j stream] --> E
Graph --> C
E --> Reflect[ConversationReflector + MemoryTagPolicy]
Reflect -->|allowlist| EP
Reflect -->|post-session| SEM
Reflect -->|denylist| X[Dropped]
EP --> Prime[ContextPrimingService OBSERVE]
SEM --> Prime
S --> UI[Cortex session drawer]
E --> UI | Plane | Store | Owns | Does not own |
|---|---|---|---|
| Operational | H2/JDBC in spector-synapse | session CRUD, chronological events, thinking, tool payloads, token usage, LangGraph4j checkpoints | semantic truth, association graphs, decay |
| Cognitive | Spector Memory | salient user facts, observed preferences, inferred insights, reflected summaries | thread replay, tool traces, graph blobs, session lists |
5.2 Operational schema¶
erDiagram
CHAT_SESSION ||--o{ CHAT_TURN : contains
CHAT_TURN ||--o{ CHAT_EVENT : emits
CHAT_SESSION ||--o{ GRAPH_CHECKPOINT : checkpoints
CHAT_SESSION {
varchar id PK
varchar title
varchar status
boolean archived
timestamp created_at
timestamp updated_at
}
CHAT_TURN {
varchar id PK
varchar session_id FK
int seq
varchar status
varchar model
int primed_count
int input_tokens
int output_tokens
bigint latency_ms
}
CHAT_EVENT {
varchar id PK
varchar turn_id FK
int seq
varchar type
clob payload_json
timestamp created_at
}
GRAPH_CHECKPOINT {
varchar thread_id PK
varchar checkpoint_id
blob state
timestamp written_at
} CHAT_EVENT.type is the same vocabulary as SSE: user, thinking, token, tool_call, tool_result, done, error.
GRAPH_CHECKPOINT.thread_id equals CHAT_SESSION.id. The saver implements LangGraph4j BaseCheckpointSaver.
Delete session deletes operational rows and checkpoints only. Cognitive engrams are not cascade-deleted; they are knowledge, not thread UI state.
5.3 Cognitive ingestion policy¶
flowchart TB
Events[Turn events from JDBC] --> Reflector
Reflector --> Extract[FACT / KNOWLEDGE extraction]
Extract --> Policy{MemoryTagPolicy + salience}
Policy -->|explicit user fact| US["remember EPISODIC<br/>MemorySource.USER_STATED"]
Policy -->|preference inferred from behavior| OB["remember EPISODIC<br/>MemorySource.OBSERVED"]
Policy -->|agent conclusion worth keeping| IN["remember EPISODIC<br/>MemorySource.INFERRED"]
Policy -->|idle / close session distill| RF["remember SEMANTIC<br/>MemorySource.REFLECTED"]
Policy -->|assistant transcript| D1[Drop]
Policy -->|thinking / CoT| D2[Drop]
Policy -->|tool args or raw output| D3[Drop]
Policy -->|session or turn or model id as tag| D4[Drop / reject] Allowlist tags: domain labels only (preference, people, decision, project, …).
Denylist tags (reject or strip with error metric):
session:,session_,id:<sessionId>type:turn,role:user,role:assistantas a substitute for a transcript storemodel:<name>
Denylist payloads: tool JSON dumps, CoT / <think> blocks, full assistant messages, graph checkpoints.
Correlation from an engram back to a thread, if ever needed, is an operational attribute on JDBC (source_turn_id on a side table) — not a synaptic tag.
5.4 Read paths¶
sequenceDiagram
actor UI as Cortex
participant API as ChatController
participant JDBC as ChatTranscriptPort
participant Graph as AgenticChatGraph
participant Cog as ChatCognitivePort
participant Mem as Spector Memory
UI->>API: POST /chat/stream
API->>JDBC: insert session/turn + user event
API->>Cog: prime(query) OBSERVE
Cog->>Mem: recall without type:turn
Mem-->>Cog: salient hits
Cog-->>API: contextBlock
API->>Graph: stream(history from JDBC + user)
loop events
Graph-->>API: thinking / token / tool_*
API->>JDBC: append CHAT_EVENT
API-->>UI: SSE
end
API->>JDBC: turn DONE
API->>Cog: reflectAsync(turn events)
Note over Cog,Mem: allowlisted facts only
UI->>API: GET /sessions/{id}/messages
API->>JDBC: load events
JDBC-->>UI: structured turns - Thread history always comes from JDBC.
- Cross-session priming always comes from Spector Memory in
RecallMode.OBSERVE(no LTP side effects). - Graph messages channel is the last N operational user/assistant texts (optionally compacted by
ConversationSummarizer), not a Memory browse oftype:turn.
5.5 Port split¶
classDiagram
class ChatTranscriptPort {
<<interface>>
createSession()
listSessions(limit)
rename(id, title)
delete(id)
appendEvent(turnId, event)
loadTurns(sessionId)
}
class ChatCognitivePort {
<<interface>>
prime(query, limit) List~PrimedMemory~
ingestSalient(SalientWrite)
}
class GraphCheckpointPort {
<<interface>>
save(threadId, checkpoint)
load(threadId)
}
class JdbcChatTranscriptAdapter
class JdbcCheckpointSaver
class SpectorSalientMemoryAdapter
class MemoryTagPolicy
ChatTranscriptPort <|.. JdbcChatTranscriptAdapter
GraphCheckpointPort <|.. JdbcCheckpointSaver
ChatCognitivePort <|.. SpectorSalientMemoryAdapter
SpectorSalientMemoryAdapter --> MemoryTagPolicy
SpectorSalientMemoryAdapter --> SpectorMemory ChatMemoryPort / SpectorMemoryChatAdapter.saveToSession become deprecated shims. AgentMemoryBridge is removed from the chat path. Other graphs may keep WORKING scratch without session-id tags.
5.6 Relationship to prior ADRs¶
| ADR | Relationship |
|---|---|
| ADR-0006 | Remains valid for salient episodic conversation structure (role, temporal chain). Synapse will not persist every operational turn as an engram. This ADR supersedes that misuse. |
| ADR-0046 / 0047 | Engram / store hierarchy unchanged. Chat writes go through MemoryRemember with MemoryType + MemorySource, never a second write API. |
| ADR-0074 | ConversationReflector is the Synapse-side feeder into ReflectPathway-style distillation (MemorySource.REFLECTED). |
| ADR-0079 | Dashboard SSE may observe chat.done / memory mutations. Per-token chat transport is request-scoped and is not the memory event bus. |
Positive Consequences¶
- Session list/delete/rename become ordinary SQL.
- Thinking and tool cards can be replayed bit-identically from
CHAT_EVENT. - Interrupted turns resume via
threadId = sessionId. - Tag index and vector space stay reserved for cognition.
- Provenance on recalled facts is trustworthy enough to show in Cortex source chips.
Negative Consequences & Trade-offs¶
- Two stores to operate, back up, and document.
- Existing
session:engrams must be migrated or forgotten. - Reflector quality becomes the bottleneck for “the agent remembers me,” instead of dumping the whole transcript into recall.
- H2 file durability is weaker than Postgres; production Synapse should plan a datasource upgrade, not a memory-as-DB fallback.
6. Pros and Cons of the Options¶
| Option | Pros | Cons |
|---|---|---|
| 1. Memory-as-transcript | One store | Tag pollution, no checkpoints, weak replay |
| 2. Dual-write everything | Redundant copies | Write amp + still poisons recall |
| 3. Dual-plane + gate (selected) | Store fit, replay, hygiene, resume | Migration + two ports |
| 4. JDBC only | Simplest hygiene | Loses automatic episodic learning |
7. Implementation Plan¶
- Phase 1 — Schema and ports. Flyway tables;
ChatTranscriptPort/JdbcCheckpointSaver; session list/rename/delete/load onChatController. StopsaveToSessionwrites. - Phase 2 — Event log as source of truth. Every SSE event appends
CHAT_EVENT.GET .../messagesreconstructs structured turns. - Phase 3 — Tag policy.
MemoryTagPolicyrejects denylist tags on all Synapse remember paths. DetachAgentMemoryBridgefrom chat. - Phase 4 — Gated reflector. Map FACT →
USER_STATED/OBSERVED, KNOWLEDGE →INFERRED, post-session summary →REFLECTED. No tool/CoT payloads. - Phase 5 — Migration. Optional dual-read of old
session:engrams; copy into JDBC;forget()migrated turns; metricchat.memory.session_tag_writesmust stay at zero. - Phase 6 — Verification. Tests listed in §8. Update ADR-0006 “Last Verified” note to point here for Synapse chat.
8. Code Reference & Verification¶
- Primary Module(s):
synapse/spector-synapse,memory/spector-memory(policy only) - Key Packages:
com.spectrayan.spector.synapse.agent.chat.servicecom.spectrayan.spector.synapse.agent.chat.infrastructurecom.spectrayan.spector.synapse.agent.cognitivecom.spectrayan.spector.kernel.api(MemorySource,MemoryType)
- Current classes this ADR changes:
SpectorMemoryChatAdapter.java— remove transcript writesChatMemoryPort.java— split / deprecateChatService.java— dual portsConversationReflector.java— provenance + allowlistAgentMemoryBridge.java— detach from chatChatController.java— session mutate + structured GET
- New classes:
JdbcChatTranscriptAdapter,JdbcCheckpointSaverSpectorSalientMemoryAdapter,MemoryTagPolicy- Flyway
Vxxx__chat_operational_plane.sql
- Verification Tests:
JdbcChatTranscriptAdapterTest— CRUD + event orderMemoryTagPolicyTest— rejectsession:/ tool JSONConversationReflectorProvenanceTest— sources and denylistChatSessionApiIT— list / rename / delete / messagesNoSessionTagsInMemoryIT— browse after a chat turn finds zerosession:tagsCheckpointResumeIT— interrupt and resume a compiled graph thread
Invariants (must remain true)¶
- No engram written by Synapse chat carries a tag matching
session[:_].+orid:<tsid>. - No engram payload written by Synapse chat is a raw tool argument object, tool result blob, or thinking scratchpad.
- Every chat-originated engram has
MemorySource ∈ {USER_STATED, OBSERVED, INFERRED, REFLECTED}. GET /api/v1/chat/sessions/{id}/messagesdoes not callSpectorMemory.browse.ContextPrimingServicedoes not callChatTranscriptPort.- Deleting a session does not call
SpectorMemory.forgetfor reflected facts.