--- status: current system: plugins created: 2026-09-16 owners: - kandev requirements: - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 - REQ-PLUGINS-PROMPT-HISTORY-HOST-006 - REQ-PLUGINS-PROMPT-HISTORY-HOST-007 --- # Conversation source reconciliation ## Purpose and authority This design replaces the durable conversation transport introduced by PR #3588. The [Host requirement](../requirements/prompt-history-extraction-host.md) owns the public boundary. The [Host prerequisite design](prompt-history-extraction-host.md) retains authority for panels, navigation, display adapters, safe DTOs, and plugin lifecycle. This document replaces its journal, version, fixed-cutoff, ACK, poison, and delivery-retention sections. It also replaces the transport mechanics in [Conversation recovery](conversation-recovery.md). The public current-state outcomes of recovery remain required. The [decision](../../../decisions/2026-09-16-conversation-source-reconciliation.md) explains the storage tradeoff. The implementation and its focused tests now match this design. ## Requirement mapping | Requirement | Design sections | | --- | --- | | REQ-PLUGINS-PROMPT-HISTORY-HOST-002 | Source reads, Scope and authorization, Facade reconciliation | | REQ-PLUGINS-PROMPT-HISTORY-HOST-005 | Compatibility, Core delivery | | REQ-PLUGINS-PROMPT-HISTORY-HOST-006 | Revision model, Incremental delivery, Facade reconciliation | | REQ-PLUGINS-PROMPT-HISTORY-HOST-007 | Upgrade and cleanup, Operational evidence | ## Current boundaries - `internal/task/repository/sqlite/conversation_source.go` owns the revision table, revision-only triggers, and source-backed reads. - `internal/task/repository/sqlite/conversation_receipts.go` returns transient mutation receipts from transaction-bound message and turn writers. - `internal/plugins/conversation_handlers.go` owns source route authorization, safe DTO mapping, and revision guards. - `internal/gateway/websocket/conversation_delivery.go` publishes post-commit v2 receipts to core and plugin scopes. - `internal/gateway/websocket/client.go` rejects stale ordered session subscriptions after cutover. - `apps/web/lib/plugins/conversation-source-scope.ts` and `conversation-host.tsx` own source reads, revision reconciliation, and terminal removal. - `apps/web/lib/ws/client.ts` owns core source projection and recovery coordination. - `apps/web/hooks/domains/session/use-session-messages.ts` and the existing message/turn hydration owners repair core state. ## Revision model Use a `conversation_session_revisions` table in the task repository. It contains `session_id` as primary key and a nonnegative `BIGINT revision`. A foreign key removes the revision with its session. No payload, event ID, timestamp history, or per-message version is stored. An existing session without a revision row reads as revision zero. Initialization creates schema and triggers without enumerating messages or turns. Small database triggers advance the session revision on relevant message and turn inserts, updates, and deletes. They replace payload-copy triggers at final cutover. They cover direct SQL, tool-payload retention, bulk operations, and FK cascades without relying on event publication. A move between sessions advances both surviving session revisions. Trigger upserts select an existing session before insertion, so cascading session deletion cannot recreate an orphan revision. Session absence itself is the terminal signal. No durable deletion tombstone is needed. Source mutation and revision increment share the transaction. Concurrent PostgreSQL writers serialize through atomic row upserts and updates. Callers that control multiple session mutations acquire revision locks in deterministic session order. Arbitrary bulk SQL can still deadlock on PostgreSQL. A deadlock aborts the whole transaction and uses the existing caller retry policy. SQLite uses its existing single-writer contract. A failed mutation or trigger rolls back both source and revision changes. Tests cover real multi-connection PostgreSQL writes and cascades. A process boot epoch supplements the database revision in Host-only responses. A changed epoch forces reconciliation after restart or restore. Revision tokens serialize as decimal strings to avoid JavaScript integer precision loss. No client can select a past revision for an as-of read. No message content hash, hash column, hash backfill, or per-write content hashing is added. Cryptographic signatures on authorization and cursor tokens remain unrelated to content hashes. ### Mutation receipts Normal message and turn writers return a transient mutation receipt from the source transaction. It contains the session, base revision, committed revision, and every operation represented by that revision interval. Operations carry stable IDs, previous selection/order fields, and the resulting persisted record or deletion identity. The receipt is an in-memory result. No receipt, changed-ID journal, or old payload is persisted. The transaction creates the revision row lazily when absent, then locks it before reading its base value. It captures the final revision and source result before commit, after all relevant triggers run. Publication occurs only after commit. A later database read cannot assign a revision to an older event payload. This prevents a concurrent writer from making incomplete data appear current. The normal single-record writer accounts for one revision increment. A transaction with several increments must describe the complete interval, or publish a reset notification instead of a certified change batch. Bulk writers and cascades without complete receipts use reset or periodic discrepancy detection. Direct SQL still increments the revision even without any receipt or bus publication. It therefore leaves a detectable gap. A process crash after commit has the same recovery behavior. Cross-session moves produce a receipt per surviving session and preserve deterministic lock order. Migrate message creation, content append, tool updates, permission/clarification updates, and turn completion to this receipt boundary. Do not duplicate original source writes to capture receipts. Uninstrumented exceptional paths remain correct through recovery, but normal streaming cannot use reset as its default. ## Source reads Extend `ConversationReader` through the task service and repository interfaces. Remove plugin SQL access after caller migration. The implemented methods read a message page, a turn page, and a revision/status tuple. Private expansion routes are `GET /api/plugins/{pluginId}/conversation/v2/task-sessions/{sessionId}/{messages|turns|revision}`. The existing binding route remains unchanged. Final source readers remain on these v2 paths. Core readers expose revision metadata through their existing authorized first-party handlers. Each page reads source rows, session existence, and revision in one short database transaction. SQLite uses a consistent read transaction. PostgreSQL uses read-only repeatable-read isolation. No transaction spans HTTP requests. Message order stays `(created_at,id)`. Preserve durable `prompt_seq`, tri-state task selection, author filters, and sort direction. Message and turn queries use bounded pages with a maximum of 100 rows. Turn keysets use `(started_at,id)` and the same task selection and revision binding. The Host assembles turn pages internally while the public turns hook keeps its existing shape. Safe DTO mapping occurs after authorized source reads. System blocks and arbitrary metadata remain excluded. The Host-only v2 page envelope adds `{epoch, revision, cursor, hasMore}` to the existing records. Signed cursors bind the user, plugin generation, session, task selection, filter, sort, page size, keyset position, epoch, and expiry. The current 10-minute cursor lifetime remains a bound, not a snapshot retention promise. Cursors identify a query and keyset boundary, not a frozen revision. A continuation separately sends the scope's fully applied revision as `expected_revision`. The page transaction compares that revision with its source revision. A mismatch returns private `409/reconciliation_required`, with no partial page commit. The Host first drains already received contiguous batches and retries once with its newer applied revision. If coverage is incomplete, it repairs the loaded range before continuation. Ordinary applied updates therefore do not invalidate every pagination cursor or force a full refresh. The Host handles that condition internally. Exhausted recovery maps to existing retryable `upstream_failure`. Malformed or mismatched tokens remain `400/invalid_query`. Expired tokens trigger one fresh authorized read cycle in the Host. No cutoff renewal route or stored token cursor is needed after migration. ## Scope and authorization Preserve authenticated plugin binding, active-generation checks, and `api_read:messages`. Every read, revision check, and subscription applies current session authorization. No task or session selection can expand through a caller-provided token. Bindings remain separate from pagination. The signing key remains because binding and cursor signatures still need it. The cache key includes panel, plugin generation, session, tri-state task selection, authors, sort, and page size. Each panel owns its request generation and abort controller. Unsubscribe, disable, uninstall, reload, and session switch cancel reads and invalidate pending commits. Fresh inaccessible or nonexistent lookups return the same `404/not_found` envelope. For a handle with a previous successful authorized read, a later 404 closes the handle and stops reads. It retains committed rows and exposes terminal `removed` state with the not-found error. This state means that the scope is no longer available. It does not distinguish deletion from revoked access. A 401 closes authorization with `unauthenticated`. No background retries continue after either terminal authorization outcome. No durable receipt, retained deletion payload, or existence-disclosing lookup is added. ## Incremental delivery Use the Host-only v2 subscription actions `session.conversation.subscribe` and `session.conversation.unsubscribe` with `protocol_version: 2`. Plugin requests carry plugin ID, generation, binding token, and Host-created scope identity. Core requests carry their distinct Host-created scope identity. The server rejects mixed identity branches. The server binds plugin scopes to their task selection and author filters before readiness. Readiness identifies the subscription, process epoch, and current revision. It does not prove that a client loaded the source state. `session.conversation.changed` carries `{epoch, base_revision, revision, operations}` and the bound subscription identity. Each operation is a message or turn upsert, or a removal by ID. Upserts carry the current full safe DTO, not a text append delta. Repeating an upsert replaces the same ID instead of appending text again. Payloads are transient network data, not database copies or retained event history. The gateway projects receipts through the scope's authorization and selection rules. An update that leaves a filter becomes removal. An update that enters a filter becomes an upsert. An unrelated change sends an empty operations list for its revision interval. That empty batch proves the interval has no effect on the selection without exposing another task's IDs or content. Only a validated, complete receipt can produce such a coverage batch. The plugin sees safe DTOs only. Core scopes use their existing richer authorized projections. The gateway can coalesce adjacent, fully accounted intervals for at most 100 ms. It can retain only the last operation per entity when the batch preserves the final selected state and all covered revisions. It must not coalesce across a missing interval or omit a relevant removal. Uncertain coalescing produces reset, not a false claim of coverage. An empty batch can be coalesced with adjacent covered batches without a payload read. ### Applied revision and discrepancy checks Each scope tracks `appliedRevision` and `observedRevision` separately. A successfully committed source snapshot establishes `appliedRevision = R`. Only complete contiguous change batches or another source snapshot can advance it. A revision check or readiness message advances only `observedRevision`. For a batch with base B and final revision R: - If B equals `appliedRevision`, apply all selected operations atomically, then set `appliedRevision = R`. - If R is no greater than `appliedRevision`, discard it as already covered. - If B exceeds `appliedRevision`, buffer briefly for an out-of-order predecessor, without claiming freshness. - If the interval partially overlaps applied state, repair rather than infer which operations are safe to skip. - If the epoch or scope differs, reject the batch and use the appropriate lifecycle recovery. An empty operations list follows the same rules. It advances coverage without changing records or triggering history reads. After revision 10, receiving only a batch covering 11 to 12 cannot hide the missing 10 to 11 batch. A check reporting revision 12 also cannot advance the applied revision from 10. Revision observations use `session.conversation.changed` with `check: true`, equal `base_revision` and `revision`, and an empty operations list. These frames report source state without proving delivery of any revision interval. A `terminal: true` frame closes only the identified subscription when its session or authorization is no longer available. It contains no message or turn payload. One gateway worker checks revision and existence for subscribed sessions every five seconds. It batches bounded session-ID queries, rechecks authorization, and reads no payloads. No subscribers means no checks. Checks equal to the applied revision cause no history reads. A newer observed revision waits up to one second for pending delivery before requesting recovery. An older revision or different epoch triggers a fresh authorized snapshot. Reconnect and visibility return also reconcile before claiming freshness. Per-scope buffering is bounded by one second, 256 operations, and 1 MiB of encoded payload, whichever limit occurs first. A larger batch, overflow, missing predecessor, malformed batch, or explicit reset schedules source recovery. Large individual messages remain readable through source pages. The bounded live path can reset for them. The existing socket backpressure path disconnects if it cannot deliver a reset safely. No durable ACK, replay log, poison queue, or permanent per-consumer cursor is introduced. ## Facade reconciliation ### Initial load and repair Subscribe before reading source records. Buffer transient batches within the limits above during hydration. Fetch the requested message range and turn pages at one common revision R. If pages disagree, discard the staged cycle and start a fresh cycle. Commit matching message pages, turns, cursor, and revision atomically for the scope. Discard buffered batches already covered by R, then apply contiguous later batches. Compare the resulting applied revision with the latest observed revision before claiming freshness. A gap schedules repair. Polling detects loss after the final comparison. Repair replaces the loaded range by authoritative IDs, including deletions. For a deleted boundary record, retain its key values as the range boundary. Fetch from the current head through the retained boundary, with the equivalent upper boundary for ascending order. A changed ordering key requires range repair unless the projection can prove complete range membership. Do not initialize or mutate `messages.bySession` for a plugin read. ### Normal live changes A matching message upsert replaces or adds that ID in deterministic order. A removal deletes that ID. A turn upsert changes only that turn and the derived duration state. Existing rows outside the loaded range remain unloaded. New head records enter the loaded range without discarding older loaded records. A move across selection boundaries uses the receipt's previous selection fields to remove obsolete entries. Unchanged filtered projections receive coverage-only batches and perform no history read. Live content updates do not refresh messages or turns in bulk. A prompt-only panel ignores agent output content, while its turn subscription still receives completion changes. Unusual range-membership changes, missing coverage, and explicit reset use repair. ### Pagination and failure `loadMore` extends the range by one requested page using the applied revision and signed keyset cursor. Buffer batches during the request. Commit the page at its returned revision, then apply contiguous later batches. Concurrent calls join. The result counts newly projected IDs against the original call's committed view. A cursor does not require renewal merely because a fully applied message update advanced the revision. Only one repair runs per scope. Additional discrepancies set one dirty bit. Automatic repair starts at most once per second per scope. Each cycle attempts at most three consistent reads with existing bounded retry delays. Persistent churn retains committed state and exposes retryable failure rather than false freshness. The next scheduled check or explicit retry starts another bounded cycle. Reconnect, expiry, authorization loss, removal, and generation changes retain the lifecycle rules in this design. ## Core delivery Core consumers use the same source-backed v2 subscription as plugin consumers. Preserve low-latency streaming through the same transaction-bound mutation receipts and applied-revision accounting. Core projections retain rich DTOs and optimistic-message rules. Plugin projections remain sanitized and cache-isolated. Use the existing core hydration coordinator only for initial load or repair. A normal contiguous event updates its ID and does not invoke broad hydration. Remove the old journal adapter and avoid parallel legacy-plus-v2 projection of the same message. Other non-conversation WebSocket traffic remains outside this revision protocol. Transport deduplication is in-memory. The system does not promise durable exactly-once event delivery. Recovery refreshes messages and turns and removes stale cached server rows. Preserve pending local messages and invalidate unloaded cache windows under existing pagination ownership. Buffer only bounded transient batches during recovery, then discard covered revisions and apply contiguous successors. Consumers without a mounted hydration owner remain dirty until an owner mounts. Terminal session removal cancels hydration and prevents resurrection. Audit all message and turn publishers when removing journal fanout. Normal source writers need complete receipts, not a second best-effort payload path with independently sampled revisions. Include task deletion, workspace cascades, Quick Chat, Office, workflow cleanup, and direct repository deletion in revision/existence tests. Exceptional deletions without receipts recover through the revision check or session absence. No new universal deletion outbox is required for a current-state view. ## Compatibility The public SDK hook and task-panel shapes do not change. `PLUGIN-API.md` must describe current-state pagination and the replacement Host-only wire protocol at cutover. Private wire v2 is the production conversation transport after this sequential implementation package. The final release contains one synchronization implementation and no fallback journal. No intermediate work order is independently released. An old browser with v1 state receives an explicit unsupported-protocol outcome after cutover. An existing browser tab requires a full reload to fetch the matching frontend. Document this upgrade step. Compatibility-only legacy fixtures do not define the production conversation contract. Reconnect and reload reauthorize plugins before data access. Old plugin bundles still use the compatible Host facade supplied by the current application. The existing minimum Host version is not changed merely because an internal transport changes. Desktop remains a dockview panel. Mobile retains Panels navigation, its inset picker, full-height panel, and one scroller. No markup, controls, touch behavior, navigation layout, or copy change is proposed. Existing mobile fixture tests prove the shared data path with touch navigation. No new UI preview is necessary for this internal state replacement. ## Upgrade and cleanup The package ships one forward migration, not a bare Git revert. Stop old backend processes before migration. Existing startup backup and migration admission remain in force. No mixed-version writers or rolling old/new backend deployment is supported during this cutover. Replace `initConversationJournalSchema` in schema initialization before it can run a historical backfill. The cutover transaction removes the exact legacy SQLite triggers and PostgreSQL triggers/functions. It installs revision-only triggers and removes the five legacy tables: `conversation_session_streams`, `conversation_session_events`, `conversation_message_versions`, `conversation_turn_versions`, `conversation_journal_meta`, plus no unrelated tables. Use catalog-aware, explicit names. Do not use a wildcard or PostgreSQL `CASCADE` that can remove unrelated dependencies. All source rows, indexes, and prompt sequences remain intact. Use the repository migration context and propagate failures before readiness. Fresh, fully migrated, partially backfilled, and partially present legacy schemas take the same idempotent path. Failure injection between DDL steps proves rollback. Reopen and repeat prove replay safety. Large legacy table removal can still cause one-time I/O. No row decoding, copying, DELETE loop, or automatic VACUUM is permitted. Remove event worker composition, journal DB injection, and every runtime open of the Host event SQLite file. After database cutover commits, remove only `.host/session-events.sqlite` and its `-wal` and `-shm` siblings. Validate that each target is the expected regular file under the configured plugin root. Refuse symlinks and unexpected paths. Do not remove `.host` itself, `conversation-token.key`, plugin storage, packages, approvals, or unrelated files. File cleanup is idempotent and runs without opening or parsing the legacy database. A file cleanup failure logs the exact non-content path and leaves an explicit retry on the next startup. It does not restart the obsolete writer or roll back the successful primary cutover. Existing backup policy remains unchanged. PostgreSQL backups remain operator-managed. SQLite DROP frees pages for reuse but does not guarantee a smaller database file. Disk reclamation uses the existing operator compaction workflow after backup. Never trigger startup VACUUM to improve a size screenshot. Downgrade after cleanup requires a pre-upgrade backup and matching binary, not a journal recreation migration. ## Operational evidence Log migration duration, cleanup completion/failure, and revision-refresh failures without payloads or credentials. Use existing structured logging. No session IDs as metric labels and no new diagnostics dashboard. A disposable benchmark records 10k and 100k messages with fixed 1 KiB content before and after cutover. Record database/WAL bytes, allocated/free pages, cold and repeated startup time, and source-write latency. Also record history-read count and bytes for a large loaded prompt range during agent streaming. After initial hydration, 100 contiguous agent updates must cause zero history rereads in a prompt-only scope. Matching message updates and turn completion must update by ID without a bulk refresh. Injected delivery loss must cause bounded repair and restore correct visible state. Separate backup time, cleanup time, and ordinary boot time. Structural gates require zero legacy payload tables/files after successful cleanup and no per-message plugin-history writes. Revision-row count cannot exceed surviving session count and cannot grow with repeated writes to one session. Timing results inform review. They do not invent a hardware-independent startup SLA. ## Implementation plans - [Storage replacement](../../../plans/conversation-storage-replacement/plan.md) records the implementation and SQLite operational evidence. - [Remaining gates](../../../plans/conversation-storage-follow-up/plan.md) owns PostgreSQL coverage, backend failures, and final recovery E2E checks.