Data Model
Conversation store schema — actors, conversations, participants, messages, relations, blobs, and branches.
The conversation store lives in Postgres alongside the runtime stream. All tables are scoped by tenant_id for multi-tenant isolation.
Entity relationship
actors (identity)
│
├── participants (stints in conversations)
│ │
│ └── conversations
│ │
│ ├── messages
│ ├── branches
│ └── message_relations
│
blobs (media metadata + S3 storage_key)actors
Chat identity — human, system, agent persona:
actors
├── id (PK)
├── tenant_id
├── created_at
└── metadata (jsonb)An actors row may link to a yoke actor_instances id via metadata when backed by a running instance. Humans have actor rows without instances.
conversations
conversations
├── id (PK)
├── tenant_id
├── created_at
└── metadata (jsonb) // title, monologue pairing hints, etc.No kind column — forum vs monologue is determined by participant sets and product conventions.
participants
One stint of an actor in one conversation:
participants
├── id (PK)
├── tenant_id
├── conversation_id → conversations.id
├── actor_id → actors.id
├── joined_at
├── left_at (nullable — null = active)
└── metadata (jsonb) // monologueId for agent forum stintsRejoining a forum creates a new participant row (new stint), not reuse of the old one.
Actor vs participant
alice (actor) ──► p1 forum F joined t0 left t1
──► p3 forum F joined t2 left null ← current stint
──► p4 monologue M joined t0 left nullVisibility is membership. Non-participants cannot see conversation messages.
messages
messages
├── id (PK)
├── tenant_id
├── conversation_id
├── type string (message, reply, reaction, system, edit, …)
├── author_participant_id → participants.id (NULL = system)
├── body jsonb — { parts[], meta? }
├── branch_id → branches.id (nullable, null = main timeline)
└── created_atNo parent_id, role, or thread_root_id columns — structure lives in type, body.parts, and message_relations.
message_relations
message_relations
├── id
├── tenant_id
├── message_id
├── relation_type reply | edit | reaction | mirror
├── target_message_id
└── created_at| Action | Insert pattern |
|---|---|
| Reply | type: message + relation: reply → target |
| Edit | type: edit + relation: edit → original |
| Reaction | type: reaction + relation: reaction → target |
| Mirror | type: message + relation: mirror → source |
branches
Soft forks within a conversation:
branches
├── id (PK)
├── tenant_id
├── conversation_id
├── forked_from_message_id → messages.id
└── created_atMain timeline: branch_id IS NULL. Branch timeline: messages set branch_id.
blobs
Media metadata (bytes in S3 when YOKE_STORAGE=s3):
blobs
├── id, tenant_id
├── mime_type, size_bytes, sha256, storage_key
├── filename?, width?, height?, duration_ms?
└── metadata, created_atQuery cheatsheet
-- Forum transcript (main timeline)
SELECT * FROM messages
WHERE tenant_id = $1 AND conversation_id = $2 AND branch_id IS NULL
ORDER BY created_at;
-- Active participants
SELECT * FROM participants
WHERE tenant_id = $1 AND conversation_id = $2 AND left_at IS NULL;Kernel ops
| Op | Description |
|---|---|
conversation_create_actor | Create chat identity |
conversation_create | Create conversation |
conversation_add_participant | Add stint |
conversation_leave_participant | End stint |
conversation_post_message | Insert + conversation.message.posted |
conversation_create_branch | Soft fork |
conversation_list_messages | Transcript read |
conversation_list_participants | Participant list |
All message posts are atomic: Postgres INSERT + stream append.