Skip to content

feat: per-sender session isolation (by Wren)#264

Open
conoremclaughlin wants to merge 7 commits intomainfrom
wren/feat/per-sender-sessions
Open

feat: per-sender session isolation (by Wren)#264
conoremclaughlin wants to merge 7 commits intomainfrom
wren/feat/per-sender-sessions

Conversation

@conoremclaughlin
Copy link
Owner

Summary

Adds contact_id as a first-class dimension on sessions and memories so external senders (Telegram/WhatsApp/Discord) get isolated conversation history and memory per contact. This is the foundation for shared SBs — agents that serve multiple external people independently.

  • Migration: Nullable contact_id FK on sessions, memories, memory_history with composite indexes and updated archive triggers
  • Contact auto-resolution: findOrCreateByPlatformId and findOrCreateGroupContact — auto-creates contacts from incoming channel messages
  • Session isolation: Contact-scoped session lookups and creation — each sender gets their own session
  • Memory scoping: remember() writes contact_id, recall() and getKnowledgeMemories() filter by it
  • Gateway integration: Message handler resolves contact from sender identity before building SessionRequest
  • Context injection: Context builder passes contactId to memory loading, RequestContextData includes it

All changes backward compatible — NULL contact_id = owner/system/inter-agent (existing behavior unchanged).

Spec

pcp://specs/per-sender-session-isolation (v2) — reviewed by Myra and Lumen.

Key design decisions:

  • Per-contact scoping is an SB-level mode (memory_model on agent_identities), not a per-memory attribute
  • In per-contact mode: contact sessions see ONLY their memories, owner sessions see everything
  • Groups are synthetic contacts (type: 'group'), DMs are individual contacts (type: 'external')
  • Activity stream already has contact-scoped indexes for conversation replay (follow-up work)

Test plan

  • Type-check passes (0 new errors)
  • Full test suite passes (1716 tests, 0 failures)
  • Manual: send Telegram message → verify contact auto-created
  • Manual: two different senders → verify separate sessions
  • Manual: remember() in sender session → verify recall() from other sender doesn't see it
  • Manual: CLI sessions still work with contact_id = NULL

🤖 Generated with Claude Code

@conoremclaughlin conoremclaughlin force-pushed the wren/feat/per-sender-sessions branch from c9a8b6f to d87695c Compare March 25, 2026 22:02
conoremclaughlin and others added 3 commits March 25, 2026 16:54
Add contact_id as a first-class dimension on sessions and memories
so external senders (Telegram/WhatsApp/Discord) get isolated
conversation history and memory per contact.

Migration: nullable contact_id FK on sessions, memories, memory_history
with composite indexes and updated archive triggers.

Contact auto-resolution: findOrCreateByPlatformId and
findOrCreateGroupContact on ContactsRepository, registered in
DataComposer.

Session layer: contact-scoped session lookups in findByUserAndAgent
and findByThreadKey. getOrCreateSession passes contactId through.

Memory scoping: remember() writes contact_id, recall() and
getKnowledgeMemories() filter by contactId. Semantic recall
post-filters since RPC functions don't support the column yet.

Gateway: messageHandler resolves contact from sender identity on
external channels before building SessionRequest.

Context: context-builder passes contactId to memory loading.
RequestContextData includes contactId for tool context.

All changes backward compatible — NULL contact_id = owner/system.

Co-Authored-By: Wren <noreply@anthropic.com>
…or handling

18 new tests covering:
- Contact auto-resolution (findOrCreateByPlatformId, findOrCreateGroupContact)
- Duplicate create race condition handling
- Contact-scoped session lookup/creation
- Session contactId mapping (DB ↔ domain)
- Contact-scoped memory remember/recall
- Owner session (null contactId) behavior

Fix: Supabase errors are plain objects, not Error instances. The race
condition catch now checks err.message and err.code directly.

Co-Authored-By: Wren <noreply@anthropic.com>
… through start_session

CLI:
- --contact-id <uuid>: direct contact scoping for per-sender isolation
- --sender <platform:id>: resolves via resolve_contact MCP tool (graceful fallback)
- contactId passed to start_session for contact-scoped session creation

API:
- start_session schema accepts contactId
- startSession() writes contact_id to DB
- getActiveSession() filters by contact_id when provided
- Fix duplicate session_transcript_archives type from rebase

Co-Authored-By: Wren <noreply@anthropic.com>
@conoremclaughlin conoremclaughlin force-pushed the wren/feat/per-sender-sessions branch from d87695c to 1adf386 Compare March 26, 2026 01:49
conoremclaughlin and others added 4 commits March 25, 2026 20:17
API endpoint resolves platform:id → contact with optional auto-create.
sb chat --sender now calls the API instead of a non-existent MCP tool.

Co-Authored-By: Wren <noreply@anthropic.com>
5 integration tests verifying:
- --contact-id passes contactId to start_session
- Different contacts create separate sessions
- Owner sessions (no flag) have no contactId
- --sender resolves via /api/admin/contacts/resolve
- User messages flow through backend with contact-scoped session

Co-Authored-By: Wren <noreply@anthropic.com>
…thread isolation

Blocker 1: remember/recall now auto-inherit contactId from session context.
- resolveContactId() checks params → request context → session context
- handleStartSession writes contactId to session context after creation
- SBs no longer need to manually thread contactId through every tool call

Blocker 2: threadKey lookup is now fully contact-isolated.
- findByThreadKey passes contactId through in session-service
- getActiveSessionByThreadKey in memory-repository filters by contact_id
- handleStartSession passes contactId to threadKey lookup

Updated existing tests to expect the new 5th arg on findByThreadKey/
getActiveSessionByThreadKey calls.

Co-Authored-By: Wren <noreply@anthropic.com>
…ollision

Lumen's final blocker: owner/system requests could still match
contact-scoped sessions because the non-threaded lookup path only
passed contactId when truthy.

Fixes:
- session-service: always passes contactId (including undefined) so
  the repo's 'contactId in options' check triggers IS NULL filtering
- memory-repository: getActiveSession and getActiveSessionByThreadKey
  always filter by contact — truthy = eq, falsy = IS NULL
- Updated test: split 'owner session' test into backward-compat
  (no options) vs explicit NULL filtering (contactId: undefined)
- New test: verifies IS NULL filter for owner session path

Co-Authored-By: Wren <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant