Consolidated three parallel team sections into wall-o.md; source sections under projects/wall-o/. Reconciled cross-team discrepancies: namespace format, chunk params, top-k, bot identity location, token-cache table, Phase 0 loop, channel naming. No plaintext secrets; Vaultwarden refs only.
25 KiB
Wall-O: Product Model and Data Architecture
Status: PLANNED First client: Wall Orthodontics Scope: STRICTLY internal staff knowledge (employee handbook, policies, procedures, billing questions). NOT patient records, NOT PHI, no HIPAA scope.
1. Core Primitive
Every channel is exactly three things bound together:
- One knowledge domain (for example "Employee Resources", "Billing", "IT Help").
- One attached AI agent (a domain-tuned persona).
- One scoped knowledge source (a set of documents/indices that maps to one vector namespace).
This is a hard invariant. A channel has exactly one agent and exactly one knowledge scope. A channel cannot span two domains, and an agent cannot serve two channels. If a practice needs a second knowledge domain, it creates a second channel, a second agent, and a second scope.
2. Canonical Architecture Split
Do not redesign this split. It is the foundation of the tenancy model.
| Layer | Responsibility | Owns |
|---|---|---|
| Rocket.Chat | Chat transport only | One workspace per tenant (MIT core, EE stripped). Rooms, users, messages, DMs. Zero intelligence. |
| Orchestrator | All intelligence | Multi-tenant Python FastAPI service. Tenancy, agents, kb_scope, M365 connector, retrieval, LLM, posting. |
| Postgres + pgvector | State and vectors | Tenants, channels, agents, scopes, documents, chunks, messages. |
| admin-ai | LLM | DeepSeek V4 Pro primary, configured fallback chain. |
| Wasabi S3 | Object storage | M365 sync staging, backups, agent assets (avatars), audit exports. |
Deployment: Rocket.Chat in Docker on netcup Core/app servers; orchestrator is FastAPI behind Caddy; Postgres + pgvector on the app data tier; M365 connector runs as a worker inside the orchestrator.
Entity relationship summary
tenants 1:N channels 1:1 agents 1:1 kb_scopes
| |
| + 1:N documents 1:N chunks
|
+ 1:N messages (per channel)
tenants 1:N users
3. Relational Data Model
All identifiers are UUIDv4. Every table that holds tenant data carries tenant_id and is filtered by it on every query. See section 9 for isolation.
3.1 tenants
One row per practice. Root of the tenancy tree.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| tenant_id | UUID | PK | Canonical identifier. |
| slug | TEXT | UNIQUE, NOT NULL | URL-safe slug, used in the webhook URL path. |
| name | TEXT | NOT NULL | Practice display name. |
| logo_url | TEXT | NULL | Branding asset, stored on Wasabi S3. |
| accent_color | TEXT | NULL | Branding hex color. |
| m365_tenant_id | TEXT | NULL | Microsoft Entra directory (tenant) id. |
| m365_client_id | TEXT | NULL | Entra app registration client id. |
| m365_credential_ref | TEXT | NULL | Vaultwarden secret reference. Never inline the client secret. |
| rocket_chat_url | TEXT | NULL | Workspace root URL for this tenant. |
| rocket_chat_admin_token_ref | TEXT | NULL | Vaultwarden reference for the admin REST token used to provision bots/rooms. |
| webhook_secret_ref | TEXT | NULL | Vaultwarden reference for the HMAC secret that signs webhook POSTs. |
| status | TEXT | NOT NULL DEFAULT 'provisioning' | provisioning, active, suspended. |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: UNIQUE (slug).
3.2 channels
One row per knowledge domain. Belongs to exactly one tenant.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| channel_id | UUID | PK | Canonical identifier. |
| tenant_id | UUID | FK to tenants.tenant_id, NOT NULL | Multi-tenant isolation. |
| name | TEXT | NOT NULL | Knowledge domain name, for example "Billing". |
| slug | TEXT | NOT NULL | URL-safe, unique per tenant. |
| description | TEXT | NULL | What this channel answers. |
| rocket_chat_room_id | TEXT | NULL | Rocket.Chat room/team id this channel maps to. |
| rocket_chat_room_name | TEXT | NULL | Human-readable room name. |
| agent_id | UUID | FK to agents.agent_id, UNIQUE, NULL | Exactly one agent per channel. Null until the agent is attached. |
| kb_scope_id | UUID | FK to kb_scopes.kb_scope_id, UNIQUE, NULL | Exactly one scope per channel. Null until bound. |
| status | TEXT | NOT NULL DEFAULT 'draft' | draft, active, archived. |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: UNIQUE (tenant_id, slug), INDEX (tenant_id), UNIQUE (agent_id), UNIQUE (kb_scope_id), INDEX (rocket_chat_room_id).
The dual agent_id/kb_scope_id unique columns enforce the one-to-one invariant from both directions.
3.3 agents
One row per AI persona. Bound to exactly one channel.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| agent_id | UUID | PK | Canonical identifier. |
| tenant_id | UUID | FK to tenants.tenant_id, NOT NULL | Multi-tenant isolation. |
| channel_id | UUID | FK to channels.channel_id, UNIQUE, NOT NULL | One agent per channel. |
| name | TEXT | NOT NULL | Agent display name, for example "Wallo Billing". |
| system_prompt | TEXT | NOT NULL | Domain-tuned persona and instructions. |
| kb_scope_id | UUID | FK to kb_scopes.kb_scope_id, NULL | What this agent may retrieve. |
| model_provider | TEXT | NOT NULL DEFAULT 'admin-ai' | LLM gateway. |
| model_name | TEXT | NOT NULL DEFAULT 'deepseek-v4-pro' | Primary model. |
| model_params | JSONB | NOT NULL DEFAULT '{}' | temperature, max_tokens, top_p. |
| fallback_model | TEXT | NULL | Next model in the failover chain. |
| rocket_chat_bot_username | TEXT | UNIQUE, NOT NULL | Bot username in Rocket.Chat. |
| rocket_chat_bot_user_id | TEXT | NULL | Rocket.Chat internal _id, filled after provisioning. |
| rocket_chat_bot_token_ref | TEXT | NULL | Vaultwarden reference for the bot personal access token. |
| status | TEXT | NOT NULL DEFAULT 'draft' | draft, provisioning, active, disabled. |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: UNIQUE (channel_id), UNIQUE (rocket_chat_bot_username), INDEX (tenant_id).
3.4 kb_scopes
One row per knowledge scope. Maps to exactly one vector namespace and one channel.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| kb_scope_id | UUID | PK | |
| tenant_id | UUID | FK to tenants.tenant_id, NOT NULL | Multi-tenant isolation. |
| channel_id | UUID | FK to channels.channel_id, UNIQUE, NOT NULL | One scope per channel. |
| vector_namespace | TEXT | UNIQUE, NOT NULL | Logical pgvector namespace, for example tenant_{tenant_id}__scope_{kb_scope_id} (see Part 2, section 5). |
| document_libraries | JSONB | NOT NULL DEFAULT '[]' | List of M365 document libraries this scope may search. Entries carry site_id, drive_id, list_id, and display name. |
| index_refs | JSONB | NOT NULL DEFAULT '[]' | Search index names the scope may query (Graph fallback). |
| embedding_model | TEXT | NOT NULL | Model used to embed chunks and queries. |
| embedding_dim | INT | NOT NULL | Vector dimension. Must match the platform-wide column dimension. |
| chunk_size | INT | NOT NULL DEFAULT 512 | Tokens per chunk (approx 2000 chars). See Part 2, section 2.3. |
| chunk_overlap | INT | NOT NULL DEFAULT 64 | Overlap tokens between chunks (approx 250 chars). See Part 2, section 2.3. |
| sync_policy | JSONB | NULL | Delta sync schedule and file-type filters. |
| last_synced_at | TIMESTAMPTZ | NULL | Last successful M365 sync. |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: UNIQUE (channel_id), UNIQUE (vector_namespace), INDEX (tenant_id).
Constraint: pgvector stores a fixed dimension per column. The platform therefore standardizes on one embedding model and dimension across all scopes so chunks.embedding is a single vector(N) column. Changing the model requires a full re-embed and reindex.
3.5 documents
One row per synced source document. Always scoped to a tenant and a channel.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| document_id | UUID | PK | |
| tenant_id | UUID | FK to tenants.tenant_id, NOT NULL | Multi-tenant isolation. |
| channel_id | UUID | FK to channels.channel_id, NOT NULL | |
| kb_scope_id | UUID | FK to kb_scopes.kb_scope_id, NOT NULL | |
| source_type | TEXT | NOT NULL | sharepoint, onedrive, manual_upload. |
| m365_drive_id | TEXT | NULL | For Graph delta sync. |
| m365_item_id | TEXT | NULL | For Graph delta sync. |
| source_path | TEXT | NULL | Full source path, for example the SharePoint URL. |
| title | TEXT | NULL | |
| file_name | TEXT | NULL | |
| mime_type | TEXT | NULL | |
| content_hash | TEXT | NOT NULL | SHA256 of raw content, for change detection. |
| size_bytes | BIGINT | NULL | |
| metadata | JSONB | NOT NULL DEFAULT '{}' | author, last modified time, page count. |
| status | TEXT | NOT NULL DEFAULT 'pending' | pending, extracting, indexing, indexed, failed, deleted. |
| last_synced_at | TIMESTAMPTZ | NULL | |
| indexed_at | TIMESTAMPTZ | NULL | |
| error | TEXT | NULL | Last sync or index error. |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: INDEX (tenant_id, channel_id), INDEX (kb_scope_id), INDEX (status), partial UNIQUE (tenant_id, m365_drive_id, m365_item_id) WHERE m365_item_id IS NOT NULL.
3.6 chunks
One row per embedded chunk. Carries the vector and the denormalized tenant/channel/scope keys for fast, isolated retrieval.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| chunk_id | UUID | PK | |
| document_id | UUID | FK to documents.document_id ON DELETE CASCADE, NOT NULL | |
| tenant_id | UUID | FK to tenants.tenant_id, NOT NULL | Denormalized for retrieval filtering. |
| channel_id | UUID | FK to channels.channel_id, NOT NULL | |
| kb_scope_id | UUID | FK to kb_scopes.kb_scope_id, NOT NULL | |
| chunk_index | INT | NOT NULL | Position within the document. |
| content | TEXT | NOT NULL | The chunk text. |
| token_count | INT | NOT NULL | |
| embedding | vector(N) | NOT NULL | pgvector column. N is the platform-wide dimension. |
| metadata | JSONB | NOT NULL DEFAULT '{}' | page number, section heading, anchor text. |
Indexes: INDEX (document_id), INDEX (tenant_id, kb_scope_id), and a vector index:
CREATE INDEX chunks_embedding_idx ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
For large tenants, partition chunks by tenant_id so each partition is its own physical namespace and the HNSW index is built per partition.
3.7 messages
One row per inbound question and per outbound answer. Doubles as the audit log and the idempotency ledger.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| message_id | UUID | PK | |
| tenant_id | UUID | FK to tenants.tenant_id, NOT NULL | Multi-tenant isolation. |
| channel_id | UUID | FK to channels.channel_id, NOT NULL | |
| agent_id | UUID | FK to agents.agent_id, NULL | Null for inbound user messages. |
| rocket_chat_message_id | TEXT | UNIQUE, NOT NULL | Rocket.Chat message _id, used as the idempotency key. |
| rocket_chat_room_id | TEXT | NOT NULL | |
| rocket_chat_user_id | TEXT | NULL | Author _id on Rocket.Chat. |
| direction | TEXT | NOT NULL | inbound, outbound. |
| role | TEXT | NOT NULL | user, assistant. |
| content | TEXT | NOT NULL | |
| citations | JSONB | NULL | Provenance array, see section 7. |
| prompt_tokens | INT | NULL | |
| completion_tokens | INT | NULL | |
| total_tokens | INT | NULL | |
| model_name | TEXT | NULL | Model that produced the answer. |
| latency_ms | INT | NULL | End to end latency. |
| status | TEXT | NOT NULL | received, resolving, retrieving, prompting, posted, failed. |
| error | TEXT | NULL | Failure detail. |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: UNIQUE (rocket_chat_message_id), INDEX (tenant_id, channel_id, created_at), INDEX (created_at).
3.8 users
One row per human staff member. Mirrors the Rocket.Chat user for identity and role mapping.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| user_id | UUID | PK | |
| tenant_id | UUID | FK to tenants.tenant_id, NOT NULL | Multi-tenant isolation. |
| rocket_chat_user_id | TEXT | NOT NULL | Rocket.Chat user _id. |
| TEXT | NULL | ||
| display_name | TEXT | NULL | |
| role | TEXT | NOT NULL DEFAULT 'staff' | admin, staff. Controls orchestrator admin surface only. |
| last_seen_at | TIMESTAMPTZ | NULL | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |
Indexes: UNIQUE (tenant_id, rocket_chat_user_id), INDEX (tenant_id).
4. Expanded Message Flow
The canonical 6-step flow, with exact mechanics.
Step 1: User posts a question
A staff member either @mentions the agent bot in a channel, or DMs the bot directly (section 8).
Step 2: Rocket.Chat fires the webhook
Channel mentions are captured by an outgoing webhook integration configured on the channel with trigger word equal to the bot username. Rocket.Chat POSTs to:
POST https://orchestrator.wall-o.<domain>/api/v1/webhook/rocketchat/{tenant_slug}
Headers:
| Header | Value |
|---|---|
| X-WallO-Signature | Hex HMAC-SHA256 of the raw request body, keyed by the tenant webhook secret. |
| X-WallO-Timestamp | Unix seconds. Reject if skew is greater than 300 seconds. |
| Content-Type | application/json |
Payload (Rocket.Chat outgoing webhook shape):
{
"token": "<outgoing webhook token>",
"channel_id": "<rocketchat room id>",
"channel_name": "employee-resources",
"timestamp": "2026-08-15T12:00:00.000Z",
"user_id": "<rocketchat user id>",
"user_name": "jane.doe",
"text": "@wallo-billing How do I submit a PTO request?",
"trigger_word": "@wallo-billing",
"bot": false
}
The orchestrator strips the trigger word and mention prefix from text before treating the remainder as the question.
DM capture does not use this webhook. Rocket.Chat outgoing webhooks do not fire inside DMs, so the orchestrator's bot-listener sidecar (section 5) receives DM events over the Realtime API and normalizes them into the identical inbound message object.
Step 3: Orchestrator resolves tenant, channel, agent, scope
Resolution order, all in one request context:
- Authenticate: verify
X-WallO-Signatureagainst the tenant webhook secret looked up bytenant_slug. - Idempotency: derive
rocket_chat_message_idfrom a hash of (channel_id, user_id, timestamp, text). If amessagesrow already exists with that id, return HTTP 200 immediately and do nothing. This deduplicates webhook retries. - Resolve channel:
SELECT * FROM channels WHERE tenant_id = ? AND rocket_chat_room_id = ?, with a fallback match onrocket_chat_room_name/slug. - Resolve agent: read
channel.agent_id, thenSELECT * FROM agents WHERE agent_id = ?. - Resolve scope: read
agent.kb_scope_id, thenSELECT vector_namespace, embedding_model FROM kb_scopes WHERE kb_scope_id = ?. - If the channel has no agent or no scope, reply with a configuration-error message (or a silent no-op, configurable per tenant) and stop.
Step 4: Retrieval
Primary path: pgvector semantic search over the channel's vector namespace.
SELECT chunk_id, document_id, content, metadata, 1 - (embedding <=> $query_vec) AS score
FROM chunks
WHERE tenant_id = $tenant_id AND kb_scope_id = $kb_scope_id
ORDER BY embedding <=> $query_vec
LIMIT 20; -- candidate set; rerank to 5 per Part 2, section 3.2
The query is embedded with the scope's embedding_model. Both tenant_id and kb_scope_id filters are mandatory in the same query, which is what makes the namespace isolated.
Fallback path: if the top score is below a configured threshold (for example 0.70) or the query returns zero rows, the orchestrator calls Microsoft Graph /search/query over the scope's document_libraries, with the search entity type set to driveItem and the site/drive scoped to the scope's libraries. Results are optionally re-ranked before prompt assembly.
Step 5: Prompt build and LLM call
The prompt is assembled from four parts:
agents.system_prompt(the domain-tuned persona).- A retrieved-context block where each chunk is labeled with a source marker
[1],[2], and so on, carrying its document title and section heading. - A citation instruction: answer using only the provided context, cite sources inline as
[n], and if the context does not contain an answer, say so instead of guessing. - The user question.
The call goes to admin-ai with model_name (DeepSeek V4 Pro primary) and model_params. A per-call timeout (for example 30 seconds) is enforced. On timeout or model error, the orchestrator retries once with fallback_model, then returns a canned "I could not reach the model" reply. It never fabricates an answer.
Step 6: Post the answer back to Rocket.Chat
POST https://<rocket_chat_url>/api/v1/chat.postMessage
Headers: X-Auth-Token: <bot token>, X-User-Id: <bot user id>
Body: { "roomId": "<room id>", "text": "<answer with [n] citations + sources footer>" }
The answer is posted as the agent bot (section 7). On post failure the orchestrator retries with exponential backoff (up to 3 attempts), then marks the message failed and records the error.
Error and timeout handling
- The webhook acknowledges immediately (HTTP 200/202) after persisting the inbound message and enqueuing async processing, so the Rocket.Chat webhook never blocks or times out on the LLM call. The answer is posted out of band via REST.
- Retrieval empty and below threshold: reply "I could not find an answer in the knowledge base for this question" with no sources. Never hallucinate.
- LLM timeout/error: failover model, then a canned error reply.
- Rocket.Chat post failure: retry with backoff, then mark failed and surface to a tenant alert.
- Poison messages go to a dead-letter status with the error retained; alerting fires on elevated failure rate.
5. Rocket.Chat Bot Integration
Recommended mechanism
Primary path: (a) bot user type + REST API, with the outgoing webhook (b) as the canonical channel-mention trigger and a websocket listener for DMs. The Apps Engine (c) is not used.
Justification: the bot user gives each agent a stable, named identity (username, avatar, alias) inside Rocket.Chat and the REST API is versioned, stateless, and fully scriptable from the orchestrator, while the outgoing webhook delivers the canonical inbound POST with no code running inside the chat layer. The Apps Engine is rejected because it would place logic inside the transport layer, violating the architecture split, and would require maintaining a JS app per tenant. Incoming webhooks alone (b by itself) cannot provide a dynamic per-agent identity, so they are used only as an optional posting convenience, not as the identity layer.
Register an agent bot
For each agent, the orchestrator (using the tenant admin token):
- Create the bot user:
POST /api/v1/users.createwith:{ "name": "<agent name>", "username": "<rocket_chat_bot_username>", "email": "<username>@<tenant-domain>", "password": "<random 32-char>", "roles": ["bot"], "joinDefaultChannels": false, "requirePasswordChange": false, "sendWelcomeEmail": false, "verified": true } - Create a personal access token:
POST /api/v1/users.createTokenwith{ "userId": "<bot _id>" }. The returnedauthTokenanduserIdare stored in Vaultwarden; the reference goes inagents.rocket_chat_bot_token_refand the _id inagents.rocket_chat_bot_user_id. - Optionally set the avatar:
POST /api/v1/users.setAvatar(uploaded image) orusers.setAvatarFromUrl.
Join a channel
The orchestrator (via the tenant admin token) invites the bot to the channel's room:
POST /api/v1/channels.invite with { "roomId": "<rocket_chat_room_id>", "userId": "<bot _id>" }.
Bots auto-accept invitations. After this the bot is a room member and can be @mentioned and post as itself.
Route @mentions and commands
- @mention/trigger word: configure an outgoing webhook integration on the channel with trigger word set to the bot username. A user mentioning the bot (or typing the trigger word) causes Rocket.Chat to POST to the orchestrator webhook URL (section 4, step 2). The orchestrator strips the mention/trigger prefix.
- Slash command (optional):
POST /api/v1/commands.createto register a command such as/askin the channel that routes to the same webhook. Useful when a channel hosts more than one purpose and explicit scoping is wanted.
Route DMs
Because outgoing webhooks do not fire in DMs, the orchestrator runs a bot-listener sidecar that maintains a websocket (Realtime API) session per bot: connect, call method: "login" with the bot token, then subscribe to stream-room-messages for the bot's own user id. Both DMs and room mentions arrive as the same message event type. The sidecar normalizes them into the identical inbound message object as the webhook path and enqueues them through the same pipeline. The sidecar is a pure transport shim; it holds no intelligence.
6. Channel Lifecycle
| Step | Action | Performed by |
|---|---|---|
| 1. Create channel | Insert a channels row (tenant, name, slug, description, status = draft). Optionally create the Rocket.Chat room via POST /api/v1/channels.create and store rocket_chat_room_id. |
Orchestrator API, tenant admin role |
| 2. Attach agent | Insert an agents row (system_prompt, model config, bot username) with channel_id set. Provision the Rocket.Chat bot user and token (section 5). Invite the bot to the room. Set channels.agent_id. |
Orchestrator API + Rocket.Chat REST |
| 3. Bind kb_scope | Insert a kb_scopes row (vector_namespace, document_libraries, embedding model/dim, chunk params). Set channels.kb_scope_id. |
Orchestrator API |
| 4. Initial sync and index | The M365 connector pulls the named document libraries, extracts text, chunks, embeds, and writes documents + chunks rows under the namespace. |
M365 connector worker |
| 5. Go live | Set channels.status = active. Configure the outgoing webhook and optional slash command. Run an end-to-end smoke test question and confirm a cited answer posts back as the bot. |
Orchestrator API + operator |
Steps 1 through 3 are idempotent API calls driven by an onboarding form in the orchestrator admin surface. Step 4 is the only long-running step; the channel stays in draft until indexed document count is nonzero. Step 5 flips status and does not require a redeploy.
7. Agent Identity and Response Citations
Identity: the bot posts with its own username and avatar (set at provisioning). Every answer is attributed to the bot user, never to a human, so staff can distinguish agent answers from colleague messages. The system_prompt instructs the agent to introduce itself as the channel's assistant (for example "I am the Wall-O assistant for Employee Resources") and to stay inside the domain.
Citations: retrieved chunks carry provenance in metadata (document title, section heading, page). During prompt assembly each chunk is labeled [1], [2], and so on. The orchestrator renders the final answer with inline [n] markers and appends a "Sources" footer listing each cited document title and a link (Microsoft Graph sharing link, or a Rocket.Chat file link). The full provenance (document_id, chunk_id, title, snippet, url) is stored in messages.citations as JSONB for audit and re-render. If the answer cites nothing, no footer is emitted.
8. User Interaction Model
| Mode | When to use | Trigger path |
|---|---|---|
| Channel @mention | Shared or discoverable questions, team-wide answers, anything others should see. | Outgoing webhook (section 4, step 2). |
| DM the bot | Private follow-up, iterative clarification, personal-but-not-PHI questions, or a 1:1 thread the user does not want in the channel. | Bot-listener websocket (section 5). |
Guidance surfaced to staff: use the channel for Q&A that benefits the team (answers stay searchable in the room), and DM the bot for private or back-and-forth questions. Both paths produce the same cited answer format; only the destination differs.
9. Multi-tenant Isolation and Security
- Tenant_id on every table: tenants, channels, agents, kb_scopes, documents, chunks, messages, and users all carry
tenant_id. The application derives the tenant from the webhook path and HMAC signature, never from a client-supplied body field alone, and scopes every query with it. - Postgres row-level security: enable RLS on all tenant tables with a policy
tenant_id = current_setting('app.tenant_id'), set per request, as defense in depth under the application-level filter. - Vector namespace isolation: retrieval always filters
tenant_idANDkb_scope_idin the same query, so a chunk can never leak across channels or tenants. Partitioning bytenant_idmakes each tenant's vectors physically separate. - Secrets: M365 credentials, bot tokens, and webhook secrets live in Vaultwarden. The database stores references only. Never commit secrets;
.env.exampleplaceholders only. - Scope guard: the product never ingests patient records or PHI. The M365 connector's document_libraries are allowlisted per scope, and a content filter flags documents outside the allowlisted libraries before indexing.