# Architecture — Chat Switchboard v0.9 ## Deployment Modes Three Docker images support different deployment scenarios: | Image | Dockerfile | Contents | Use Case | |-------|-----------|----------|----------| | **Unified** | `Dockerfile` | nginx + Go backend | Dev, docker-compose, single-node | | **Backend** | `server/Dockerfile` | Go binary only | K8s — scale API independently | | **Frontend** | `Dockerfile.frontend` | nginx + static files | K8s — scale FE independently | **Unified** bundles everything in one container. Nginx serves static files and proxies `/api/*` and `/ws` to the Go backend running on `:8080`. Good for development and small deployments. **Split (Backend + Frontend)** for production K8s: Ingress routes `/api/*` and `/ws` to the backend Service, everything else to the frontend Service. The frontend entrypoint (`docker-entrypoint-fe.sh`) handles `BASE_PATH` injection into `index.html` and dynamic nginx config generation at startup. Supports branding volume mounts at `/branding/`. ``` ┌─────────────────────────┐ │ Ingress / Traefik │ │ ├─ /api/* → be-svc:8080 │ │ ├─ /ws → be-svc:8080 │ │ └─ /* → fe-svc:80 │ └─────────────────────────┘ │ │ ┌──────────▼──┐ ┌───────▼────────┐ │ Backend │ │ Frontend │ │ (Go :8080) │ │ (nginx :80) │ │ replicas:N │ │ replicas:M │ └──────┬──────┘ └────────────────┘ │ ┌──────▼──────┐ │ PostgreSQL │ └─────────────┘ ``` ## Design Principles 1. **Persona-as-Trust-Boundary**: A persona (model + config + prompt) is the unit of access control. Users interact with personas, not raw provider configs. Admins control which models are visible; team admins control which personas their team can use. 2. **Roles vs Teams**: Clean separation between vertical permissions (Roles: admin, user) and horizontal visibility (Teams: organizational units). A user's role determines what they can *do*; their team membership determines what they can *see*. 3. **Store Layer**: All database access goes through typed Go interfaces (`store.Stores`). Handlers never write raw SQL. This enables future portability (SQLite for dev, Postgres for prod) and testability (mock stores). 4. **Scope Model**: Provider configs, personas, and model settings all use a three-value `scope` column: `global` (admin-managed, visible to all), `team` (team-admin-managed, visible to team), `personal` (user-managed, visible to owner). The `owner_id` column points to the owning user or team depending on scope. 5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID). No static model table — the same model can have different capabilities on different providers. The catalog is populated by auto-fetch on provider creation and manual refresh. 6. **Channels as Execution Context**: Channels are the universal container for conversation state — messages, tool activity, notes, and artifacts all hang off a channel. Today channels are single-user direct chats. The architecture anticipates multi-participant channels (team members, anonymous visitors, AI personas) for workflow execution, without requiring changes to the message tree, tool framework, or streaming infrastructure. ## Workflow Architecture (Future — v0.21.0+) The platform's existing primitives (teams, personas, channels, tools, notes) compose into a workflow engine where the channel is the execution context that moves through defined stages. ### Conceptual Model ``` Team └─ Workflow (team-admin defined) ├─ name, description ├─ entry conditions (public link, team-internal, API trigger) └─ Stages[] ├─ persona_id (which AI drives this stage) ├─ assignment_team_id (who can be assigned) ├─ form_template (structured note schema, optional) └─ transitions[] (conditions → next stage) Channel (workflow instance) ├─ workflow_id + current_stage ├─ participants[] │ ├─ anonymous visitor (mTLS fingerprint / session token) │ ├─ AI persona (per-stage, from workflow definition) │ └─ assigned team member (claimed or auto-routed) ├─ messages (existing tree structure) ├─ notes (channel-scoped artifacts, intake forms) └─ tool activity (existing execution framework) ``` ### How Existing Primitives Map | Existing Primitive | Workflow Role | |-------------------|---------------| | **Persona** (system prompt + model) | Drives a workflow stage — the AI knows what to collect, when to route | | **Team** (members + roles) | Owns the workflow definition; members are assignable to channels | | **Channel** (messages + tree) | Execution instance of a workflow; full conversation history | | **Notes + Tools** | Structured data collection; the persona's system prompt *is* the form definition, the note *is* the filled form | | **EventBus + WebSocket** | Real-time notifications for assignment, stage transitions, new messages | ### Design Constraints for Current Development These invariants keep the workflow path open without building it prematurely: - **Channels**: Don't assume single-owner. If touching channel queries, keep room for `team_id`, `type` (beyond `direct`), and multi-participant access patterns alongside `user_id`. - **Notes**: Currently user-scoped. Future channel-scoped notes (attached to a conversation, not a personal notebook) need a `channel_id` FK option. - **Personas**: Already team-scoped. Don't couple to "user picks from dropdown" — a workflow stage references a persona programmatically. - **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need `TeamID` and `WorkflowID` — the struct is easily extended. - **Auth**: mTLS anonymous users need identity to participate in channels. Lightest version: a `participants` table that can reference a `user_id` or an opaque session identifier (cert fingerprint). Don't assume every channel participant has a row in `users`. ## Package Structure ``` server/ ├── main.go # Wiring: stores → handlers → routes ├── config/config.go # Env-based configuration ├── database/ │ ├── database.go # Connection management │ ├── migrate.go # Auto-migration on startup │ └── migrations/ │ └── 001_v09_schema.sql # Consolidated schema ├── store/ │ ├── interfaces.go # Store interfaces + shared types │ └── postgres/ # Postgres implementations │ ├── stores.go # NewStores() constructor │ ├── provider.go # ProviderStore │ ├── catalog.go # CatalogStore │ ├── persona.go # PersonaStore │ ├── user.go # UserStore │ ├── team.go # TeamStore │ ├── policy.go # PolicyStore │ ├── audit.go # AuditStore │ └── ... ├── models/models.go # Shared domain types ├── capabilities/ │ ├── intrinsic.go # Heuristic detection + resolution │ └── resolver.go # ModelsForUser() unified resolver ├── handlers/ # HTTP handlers (Gin) │ ├── auth.go # Login, register, refresh, logout │ ├── admin.go # User/config/model management │ ├── channels.go # Channel CRUD │ ├── messages.go # Message CRUD + forking │ ├── completion.go # Chat completions (SSE streaming) │ ├── capabilities.go # Model list + ResolveModelCaps │ ├── presets.go # Persona CRUD (all scopes) │ ├── apiconfigs.go # User provider config CRUD (BYOK) │ ├── teams.go # Team management │ └── ... ├── providers/ # LLM provider adapters │ ├── provider.go # Provider interface │ ├── anthropic.go │ ├── openai.go │ ├── openrouter.go │ └── venice.go ├── middleware/ # Auth, admin, CORS, rate limiting ├── events/ # EventBus + WebSocket hub └── tools/ # Built-in tool definitions (notes) ``` ## Store Layer Pattern Every store follows the same pattern: ```go // Interface in store/interfaces.go type FooStore interface { Create(ctx context.Context, f *models.Foo) error GetByID(ctx context.Context, id string) (*models.Foo, error) Update(ctx context.Context, id string, patch models.FooPatch) error Delete(ctx context.Context, id string) error List(ctx context.Context, opts ListOptions) ([]models.Foo, int, error) } // Implementation in store/postgres/foo.go type fooStore struct{ db *sql.DB } func (s *fooStore) Create(ctx context.Context, f *models.Foo) error { ... } ``` Handlers receive `store.Stores` (a bundle of all store interfaces): ```go type AdminHandler struct { stores store.Stores } func (h *AdminHandler) CreateUser(c *gin.Context) { // ... err := h.stores.Users.Create(c.Request.Context(), user) } ``` ## Capabilities Resolution Chain When the system needs to know what a model can do (vision? tools? thinking?): ``` 1. model_catalog DB (exact match: model_id + provider_config_id) ↓ miss 2. model_catalog DB (any provider: same model_id) ↓ miss 3. Heuristic inference (name-based: "gpt-4-vision" → vision=true) ``` The `capabilities.ModelsForUser()` function combines catalog entries, team personas, and user preferences into a single unified model list for the frontend. ## Scope / Ownership Model ``` scope='global' → owner_id=NULL → Admin-managed, visible to all scope='team' → owner_id=team.id → Team-admin-managed, visible to team scope='personal' → owner_id=user.id → User-managed, visible to owner ``` Used by: `provider_configs`, `personas`, `model_catalog` (visibility column adds `enabled`/`disabled`/`team` states on top). ## Schema Migration Single consolidated migration (`001_v09_schema.sql`) replaces the previous 21 incremental migrations. The Go backend auto-migrates on startup: 1. Creates `schema_migrations` table if absent 2. Checks which migration files have been applied 3. Applies any new `.sql` files in order ## Frontend Architecture Vanilla JavaScript, no build step. Five files with clear responsibilities: | File | Role | |------|------| | `api.js` | HTTP client with token refresh. All backend calls. | | `app.js` | Application state machine. Business logic. | | `ui.js` | DOM rendering. All `document.createElement` calls. | | `events.js` | Labeled event bus with WebSocket bridge. | | `debug.js` | Admin debug panel (model list, stats, config). | Communication: `app.js` calls `API.*` methods, updates state, then calls `UI.*` methods to render. `events.js` handles real-time updates via WebSocket. No framework, no virtual DOM, no reactive bindings. ## Security Model - **Auth**: JWT access tokens (short-lived) + refresh tokens (DB-stored, revocable) - **Admin Bootstrap**: `SWITCHBOARD_ADMIN_USERNAME`/`PASSWORD` env vars create/update admin on every startup (K8s secret pattern) - **API Key Storage**: Provider API keys stored in `api_key_enc` column (TODO: at-rest encryption) - **Policies**: Boolean flags in `global_settings` table control registration, BYOK, team providers, etc. - **Audit**: All admin operations logged to `audit_log` with actor, action, resource type/ID, and diff - **Banner**: Environment classification banner configurable via admin settings (text, color, position) - **CORS**: Configurable allowed origins via env var - **No sensitive terminology**: Banner system avoids classification-related terms for security compliance ## Backward Compatibility v0.9 maintains backward-compatible API routes: | Old Route | New Handler | Notes | |-----------|-------------|-------| | `/api/v1/presets` | PersonaHandler | Returns both `personas` and `presets` keys | | `/api/v1/api-configs` | ProviderConfigHandler | Unchanged route, new implementation | | `/api/v1/models` | ModelHandler.ListEnabledModels | Alias for `/models/enabled` | JSON field rename: `api_config_id` → `provider_config_id` in channel and completion request/response bodies. Frontend updated to match.