This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/DESIGN-0.19.0.md
2026-02-28 23:46:23 +00:00

42 KiB

Design — v0.19.0 Projects / Workspaces

Version: 0.19.0 Status: Draft Depends on: user groups + resource grants (v0.16.0), knowledge bases (v0.14.0), side panel (v0.18.1)


1. Overview

Projects are organizational containers that group related conversations, knowledge bases, and notes into a shared workspace. They sit between "individual channel" and "team" in the hierarchy — a team might have many projects, a user might have personal projects across teams.

Projects solve two concrete problems:

  1. Sidebar sprawl: Power users accumulate dozens of channels. The flat (or single-folder) chat list doesn't scale. Projects give channels meaningful grouping with shared context.

  2. Shared context: Today, attaching a KB or Persona to a channel is per-channel. Projects let you say "every conversation in this project uses these KBs and this Persona" — one config, many channels.

What this release does NOT include: Notifications are deferred to v0.19.1 (see §12 for the split rationale and forward pointers).


2. Scope Model

Projects reuse the existing three-value scope model:

┌─────────────────────────────────────────────────┐
│ personal scope                                  │
│ owner_id = user_id, team_id = NULL              │
│ Only visible to the owning user                 │
│ Use case: personal workspace, side projects     │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│ team scope                                      │
│ owner_id = creator, team_id = team_id           │
│ Visible to team members (respects grants)       │
│ Use case: department projects, client work      │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│ global scope                                    │
│ owner_id = admin, team_id = NULL                │
│ Visible to all users (admin-created)            │
│ Use case: company-wide initiatives, templates   │
└─────────────────────────────────────────────────┘

Access control: Projects use the same grant model as Personas and KBs (v0.16.0): team_only, global, or groups. The resource_grants table gets 'project' added to its resource_type CHECK constraint.


3. Data Model

3.1 Table: projects

Column Type Description
id UUID / TEXT Primary key
name TEXT NOT NULL Display name
description TEXT DEFAULT '' Optional description
scope TEXT NOT NULL personal, team, global
owner_id UUID / TEXT Creating user
team_id UUID / TEXT NULL for personal/global
persona_id UUID / TEXT Default Persona for new channels (nullable)
settings JSONB / JSON Project-level config (see §3.7)
is_archived BOOLEAN DEFAULT false Soft archive
created_at TIMESTAMPTZ / TEXT
updated_at TIMESTAMPTZ / TEXT

Constraints:

CONSTRAINT project_scope_check CHECK (
    (scope = 'personal' AND team_id IS NULL) OR
    (scope = 'team' AND team_id IS NOT NULL) OR
    (scope = 'global' AND team_id IS NULL)
)

Indexes:

CREATE INDEX idx_projects_owner ON projects(owner_id);
CREATE INDEX idx_projects_team  ON projects(team_id) WHERE team_id IS NOT NULL;
CREATE INDEX idx_projects_scope ON projects(scope);

3.2 Junction Tables

Separate junction tables per resource type — not a polymorphic project_resources table. Rationale in §3.3.

project_channels

Column Type Description
project_id UUID / TEXT FK → projects ON DELETE CASCADE
channel_id UUID / TEXT UNIQUE, FK → channels ON DELETE CASCADE
position INT DEFAULT 0 Sort order within project
folder_path TEXT DEFAULT '/' Sub-folder within project
added_at TIMESTAMPTZ / TEXT
PRIMARY KEY (project_id, channel_id)
UNIQUE (channel_id)  -- one project per channel (see §16 Q2)
CREATE INDEX idx_project_channels_channel ON project_channels(channel_id);

project_knowledge_bases

Column Type Description
project_id UUID / TEXT FK → projects ON DELETE CASCADE
kb_id UUID / TEXT FK → knowledge_bases ON DELETE CASCADE
auto_search BOOLEAN DEFAULT true Include in all project channels
added_at TIMESTAMPTZ / TEXT
PRIMARY KEY (project_id, kb_id)

project_notes

Column Type Description
project_id UUID / TEXT FK → projects ON DELETE CASCADE
note_id UUID / TEXT FK → notes ON DELETE CASCADE
added_at TIMESTAMPTZ / TEXT
PRIMARY KEY (project_id, note_id)

3.3 Why Three Tables, Not One

The roadmap spec proposed a single project_resources table with (resource_type, resource_id). Three tables are better here:

  • Referential integrity. FK constraints enforce that resources exist. ON DELETE CASCADE handles cleanup when a channel, KB, or note is deleted — no application-level orphan scanning.

  • Existing pattern. channel_knowledge_bases, persona_knowledge_bases, and group_members all use dedicated junction tables.

  • Type-specific columns. project_channels needs position and folder_path. project_knowledge_bases needs auto_search. A polymorphic table would need nullable type-specific columns or a JSONB bag, which is worse.

  • SQLite compatibility. Simpler constraints, no CHECK on polymorphic type strings, no mixed-resource indexes.

3.4 Channel Integration

Channels get a denormalized project_id for fast sidebar queries. The junction table project_channels is the source of truth for position and folder_path. Both are kept in sync by the store layer.

ALTER TABLE channels ADD COLUMN project_id UUID
    REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX idx_channels_project
    ON channels(project_id) WHERE project_id IS NOT NULL;

On project delete: Channels detach (project_id → NULL). They are never cascade-deleted — only the junction row is removed. KBs and notes similarly survive; only the association is dropped.

3.5 Folder/Column Cleanup

The channels table currently has two unused grouping columns:

folder_id  UUID   -- FK placeholder, never wired to a folders table
folder     TEXT   -- frontend-managed text label, barely used

With projects providing real hierarchy:

  • folder_idDropped in this migration (confirmed unused in store layer and all handlers).
  • folderDeprecated. Column remains for backward compat but is no longer read or written by any handler after v0.19.0. Migration best-effort copies non-empty folder values into personal projects (one project per distinct folder name, channels attached). Can be dropped in a future schema consolidation.

3.6 Model Structs

type Project struct {
    BaseModel
    Name        string  `json:"name" db:"name"`
    Description string  `json:"description" db:"description"`
    Scope       string  `json:"scope" db:"scope"`
    OwnerID     string  `json:"owner_id" db:"owner_id"`
    TeamID      *string `json:"team_id,omitempty" db:"team_id"`
    PersonaID   *string `json:"persona_id,omitempty" db:"persona_id"`
    Settings    JSONMap `json:"settings,omitempty" db:"settings"`
    IsArchived  bool    `json:"is_archived" db:"is_archived"`

    // Computed (from COUNT joins, not stored)
    ChannelCount int `json:"channel_count,omitempty"`
    KBCount      int `json:"kb_count,omitempty"`
    NoteCount    int `json:"note_count,omitempty"`
}

type ProjectPatch struct {
    Name        *string `json:"name,omitempty"`
    Description *string `json:"description,omitempty"`
    PersonaID   *string `json:"persona_id,omitempty"`
    Settings    JSONMap `json:"settings,omitempty"`
    IsArchived  *bool   `json:"is_archived,omitempty"`
}

type ProjectChannel struct {
    ProjectID  string `json:"project_id" db:"project_id"`
    ChannelID  string `json:"channel_id" db:"channel_id"`
    Position   int    `json:"position" db:"position"`
    FolderPath string `json:"folder_path" db:"folder_path"`
}

3.7 Project Settings (JSONB)

The settings column stores project-level configuration as JSON. Initial keys:

{
  "auto_persona": true,
  "auto_kbs": true,
  "default_model": null,
  "description_visible": true
}
Key Type Description
auto_persona bool Apply project Persona to new channels automatically
auto_kbs bool Inject project KBs into channels at completion time
default_model string Override model for new channels (nullable)
description_visible bool Show description in sidebar project header

Settings are intentionally sparse. Resist the urge to add per-project copies of global settings — use project Persona config for model behavior and project KBs for context.


4. Store Interface

type ProjectStore interface {
    // CRUD
    Create(ctx context.Context, p *models.Project) error
    GetByID(ctx context.Context, id string) (*models.Project, error)
    Update(ctx context.Context, id string, patch models.ProjectPatch) error
    Delete(ctx context.Context, id string) error

    // Listing (scope-aware)
    ListForUser(ctx context.Context, userID string, opts store.ListOptions) ([]models.Project, int, error)
    ListForTeam(ctx context.Context, teamID string, opts store.ListOptions) ([]models.Project, int, error)
    ListAccessible(ctx context.Context, userID string) ([]models.Project, error)

    // Resource association
    AddChannel(ctx context.Context, projectID, channelID string) error
    RemoveChannel(ctx context.Context, projectID, channelID string) error
    ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error)
    MoveChannel(ctx context.Context, projectID, channelID string, position int, folderPath string) error

    AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error
    RemoveKB(ctx context.Context, projectID, kbID string) error
    ListKBs(ctx context.Context, projectID string) ([]models.KnowledgeBase, error)

    AddNote(ctx context.Context, projectID, noteID string) error
    RemoveNote(ctx context.Context, projectID, noteID string) error
    ListNotes(ctx context.Context, projectID string) ([]models.Note, error)

    // KB resolution (for completion-time injection)
    GetProjectKBsForChannel(ctx context.Context, channelID string) ([]models.KnowledgeBase, error)

    // Access check
    UserCanAccess(ctx context.Context, userID, projectID string) (bool, error)
}

Key design decisions:

  • AddChannel writes both project_channels junction row and channels.project_id denorm column in a single transaction. If the channel already belongs to a different project, AddChannel removes the old association first (atomic move). This enables drag-between-projects and "Move to project" context menu.
  • RemoveChannel clears channels.project_id and deletes the junction row.
  • GetProjectKBsForChannel joins channels → project_channels → project_knowledge_bases → knowledge_bases — used at completion time for virtual KB injection (see §6).
  • ListAccessible uses the same grant resolution pattern as PersonaStore.ListAccessible: union of personal + team + granted.
  • Both Postgres and SQLite implementations required (same pattern as all other stores).

5. API Endpoints

All endpoints require authentication. Team/global project mutations require team_admin or admin role respectively.

5.1 Projects CRUD

POST   /api/v1/projects                    Create project
GET    /api/v1/projects                    List accessible projects
GET    /api/v1/projects/:id                Get project (with resource counts)
PUT    /api/v1/projects/:id                Update project
DELETE /api/v1/projects/:id                Delete project (detaches resources)

Create request:

{
  "name": "Q3 Launch",
  "description": "Product launch planning",
  "scope": "team",
  "team_id": "uuid",
  "persona_id": "uuid-or-null",
  "settings": {}
}

List response:

{
  "data": [
    {
      "id": "uuid",
      "name": "Q3 Launch",
      "scope": "team",
      "channel_count": 5,
      "kb_count": 2,
      "note_count": 12,
      "persona_id": "uuid",
      "updated_at": "2026-02-28T..."
    }
  ],
  "total": 3,
  "page": 1,
  "per_page": 50
}

5.2 Resource Association

POST   /api/v1/projects/:id/channels           Add channel to project
DELETE /api/v1/projects/:id/channels/:cid       Remove channel from project
GET    /api/v1/projects/:id/channels            List project channels
PUT    /api/v1/projects/:id/channels/:cid       Move/reorder channel

POST   /api/v1/projects/:id/knowledge-bases           Add KB
DELETE /api/v1/projects/:id/knowledge-bases/:kid       Remove KB
GET    /api/v1/projects/:id/knowledge-bases            List project KBs

POST   /api/v1/projects/:id/notes               Add note
DELETE /api/v1/projects/:id/notes/:nid           Remove note
GET    /api/v1/projects/:id/notes                List project notes

Add channel request:

{
  "channel_id": "uuid",
  "folder_path": "/design",
  "position": 0
}

Move channel request:

{
  "position": 2,
  "folder_path": "/design/iterations"
}

5.3 Project Grants

Reuses the existing resource grants API pattern:

GET    /api/v1/projects/:id/grants          Get project grant
PUT    /api/v1/projects/:id/grants          Set project grant

Request/response uses existing grant model:

{
  "grant_scope": "groups",
  "granted_groups": ["uuid-group-1", "uuid-group-2"]
}

Migration: Add 'project' to resource_grants.resource_type CHECK:

ALTER TABLE resource_grants DROP CONSTRAINT resource_grants_resource_type_check;
ALTER TABLE resource_grants ADD CONSTRAINT resource_grants_resource_type_check
    CHECK (resource_type IN ('persona', 'knowledge_base', 'project'));

5.4 Channel List Enhancement

The existing GET /api/v1/channels endpoint gains project filtering:

GET /api/v1/channels?project_id=uuid       Channels in a project
GET /api/v1/channels?project_id=none        Unassigned channels only

Technical debt addressed here: The ListChannels handler currently does raw SQL against database.DB instead of using ChannelStore. This gets migrated to the store layer as part of this work. The handler is the only one still bypassing the store — fixing it improves both maintainability and SQLite compat testing.


6. KB Injection at Completion Time

Project KBs use virtual injection at completion time — the same pattern as Persona-KB binding (v0.17.0). No physical rows are created in channel_knowledge_bases.

Priority chain for KB resolution:

channel KBs (explicit per-channel)     →  highest priority
      ↓
project KBs (auto_search = true)       →  inherited from project
      ↓
persona KBs (persona_knowledge_bases)  →  inherited from Persona

At completion time, loadConversation() already calls buildKBContext() which resolves channel + persona KBs. This extends to include project KBs:

// In completion handler, after resolving channel
func resolveKBs(ctx context.Context, stores store.Stores,
    channelID string, personaID *string) []string {

    var kbIDs []string

    // 1. Channel-level (explicit)
    channelKBs, _ := stores.KnowledgeBases.ListForChannel(ctx, channelID)
    kbIDs = append(kbIDs, extractIDs(channelKBs)...)

    // 2. Project-level (virtual injection)
    projectKBs, _ := stores.Projects.GetProjectKBsForChannel(ctx, channelID)
    kbIDs = append(kbIDs, extractIDs(projectKBs)...)

    // 3. Persona-level
    if personaID != nil {
        personaKBs, _ := stores.KnowledgeBases.ListForPersona(ctx, *personaID)
        kbIDs = append(kbIDs, extractIDs(personaKBs)...)
    }

    return deduplicate(kbIDs)
}

Why virtual, not physical: Adding a KB to a project should immediately apply to all project channels without backfilling junction rows. Removing a KB should immediately stop applying. The Persona-KB pattern proved this works — query-time resolution is simple and the JOIN cost is negligible for the cardinality involved.


7. Sidebar UI

7.1 Layout

The sidebar transforms from a flat chat list to a grouped view:

┌──────────────────────────────┐
│ 🔀 Chat Switchboard          │
│                              │
│ 🔍 Search...                 │
│                              │
│ [+ New Chat]                 │
│                              │
│ 📌 Pinned                    │
│   Chat about deployment      │
│   Weekly standup notes        │
│                              │
│ ▼ Q3 Launch (team)       ⚙  │
│   ├── /design                │
│   │   Chat: mockup review    │
│   │   Chat: color palette    │
│   ├── /copy                  │
│   │   Chat: landing page     │
│   └── Chat: kickoff          │
│                              │
│ ▼ Side Projects (personal) ⚙ │
│   Chat: rust experiments     │
│   Chat: garden planner       │
│                              │
│ ▶ Archived Project        ⚙  │
│                              │
│ ── Recent ──                 │
│   Chat: quick question       │
│   Chat: random thought       │
│                              │
│ [+ New Project]              │
└──────────────────────────────┘

Sections (top to bottom):

  1. Pinned — pinned channels, regardless of project assignment (existing behavior, unchanged)
  2. Projects — collapsible groups, sorted by updated_at desc. Each project shows its channels organized by folder_path.
  3. Recent — unassigned channels (project_id IS NULL, is_pinned = false), sorted by updated_at desc. This is the existing chat list behavior for channels not yet organized.

7.2 Interactions

Action Behavior
Click project header Toggle expand/collapse (state persisted)
Click ⚙ gear icon Open project settings in side panel
Drag channel → project POST /projects/:id/channels
Drag channel → "Recent" DELETE /projects/:id/channels/:cid
Drag channel within project PUT /projects/:id/channels/:cid (reorder)
Right-click project Context menu: rename, archive, delete, manage KBs
[+ New Project] Inline creation: name input → scope picker
Double-click project name Inline rename (same UX as chat rename from v0.17.0)

7.3 State Persistence

// sessionStorage key: 'cs-project-collapse'
// Value: JSON object { [projectId]: boolean }
// true = collapsed, absent/false = expanded

7.4 Data Loading

The sidebar needs all projects + their channels in one round trip to avoid N+1 rendering. Two options:

Option A — Two calls (preferred):

GET /api/v1/projects              →  project list with counts
GET /api/v1/channels?per_page=200 →  all channels (project_id included)

Client-side grouping by project_id. This reuses existing channel loading and adds one extra call. Channel data is already loaded at startup in loadChats().

Option B — Single enriched call:

GET /api/v1/projects?include=channels

Returns projects with embedded channel arrays. More efficient but requires a new response shape and duplicates channel data if the flat list is also needed.

Option A wins on KISS. The project list is small (rarely > 20), the channel list is already loaded, and grouping is trivial in JS.

7.5 Mobile

On viewports ≤ 768px:

  • Projects render as collapsible accordions (same as desktop)
  • Drag-and-drop disabled — use context menu → "Move to project" instead
  • Project gear icon opens a bottom sheet instead of side panel
  • Folder paths shown as flat prefixes (no tree indentation)

8. Project Settings Panel

Project settings open in the side panel (v0.18.1 PanelRegistry). Registers as 'project-settings' panel.

Sections:

  1. General — name, description, scope badge (read-only for non-admin), archive toggle
  2. Default Persona — persona picker dropdown (same component as channel persona picker from persona-kb.js)
  3. Knowledge Bases — checkbox list of accessible KBs with auto_search toggle per KB
  4. Access — grant picker (team_only / global / groups), reuses the same grant picker UI from Persona settings
  5. Resources — summary counts (N channels, N KBs, N notes)
  6. Danger Zone — delete project (confirmation dialog: "Resources will be detached, not deleted")

9. Channel Creation Within Projects

When creating a new channel from within a project context (clicking "New Chat" while a project is expanded, or via project context menu):

  1. Channel created via existing POST /api/v1/channels
  2. Immediately associated via POST /projects/:id/channels
  3. If project has auto_persona: true and persona_id set, the channel inherits the project's default Persona
  4. Project KBs automatically apply at completion time via virtual injection (§6) — no explicit channel-KB linking needed

The frontend tracks an "active project context" so new channels created while browsing a project are auto-associated. Context clears when the user clicks outside any project or into "Recent."

// In app state
App.activeProjectId = null;  // set when user interacts with a project

10. Enterprise Use Cases

10.1 Project Templates (Phase 3 stretch)

Team admins create projects pre-configured with Personas, KBs, and settings. Templates are projects with settings.is_template: true. Creating "from template" copies configuration into a new project.

Not in initial delivery — data model supports it without changes.

10.2 Cross-Team Projects

Global-scope projects with group grants enable cross-team work. Members of granted groups see the project and its channels. Uses existing grant resolution from v0.16.0 — no new access control code.

10.3 Admin Oversight

Global admins can list all projects via admin panel. New "Projects" section under the "People" category (alongside Teams and Groups). Table view with scope, owner, team, resource counts, last updated.


11. Implementation Phases

Phase 1 — Data Model + Store + API

Backend foundation. All store operations, migrations, and API endpoints.

New files:

  • server/database/migrations/006_v0190_projects.sql (Postgres)
  • server/database/migrations/sqlite/006_v0190_projects.sql (SQLite)
  • server/store/postgres/project.go (ProjectStore implementation)
  • server/store/sqlite/project.go (ProjectStore implementation)
  • server/handlers/projects.go (CRUD + resource association + grants)

Modified files:

  • server/models/models.go — add Project, ProjectPatch, ProjectChannel structs
  • server/store/interfaces.go — add ProjectStore interface, Projects field to Stores
  • server/store/postgres/stores.go — wire ProjectStore in NewStores()
  • server/store/sqlite/stores.go — wire ProjectStore in NewStores()
  • server/main.go — wire project routes, inject store
  • server/handlers/completion.go — extend KB resolution chain (§6)
  • server/handlers/channels.go — migrate ListChannels to store layer, add project_id filter parameter

Technical debt resolved:

  • ListChannels handler: raw SQL → ChannelStore (only remaining handler bypassing the store layer)
  • folder_id column dropped (unused FK placeholder)

Phase 2 — Sidebar UI

Frontend project grouping, collapse/expand, drag-and-drop.

New files:

  • src/js/projects.js — ProjectManager: CRUD calls, sidebar rendering, drag handlers, active project context, collapse state

Modified files:

  • src/js/ui-core.jsrenderChatList() → delegates to ProjectManager for grouped rendering, falls back to flat list when no projects exist
  • src/js/chat.jsloadChats() adds project fetch, channel objects include project_id
  • src/js/api.js — project API methods (CRUD, resource association)
  • src/js/app.jsApp.activeProjectId, keyboard shortcuts, project context for new channel creation
  • src/css/styles.css — project group styles, folder indent, drag target highlights, collapse animations
  • src/index.html — sidebar structure updates, "New Project" button

Phase 3 — Settings Panel + Admin

Side panel project settings, admin panel integration.

New files:

  • src/js/project-settings.js — PanelRegistry registration, settings form, KB picker, grant picker, delete flow

Modified files:

  • src/js/panels.js — project-settings panel registration
  • src/js/ui-admin.js — admin "Projects" section (table + CRUD)
  • src/js/persona-kb.js — extract KB picker as reusable component for both Persona and Project settings
  • src/css/styles.css — settings panel styles

12. Deferred: Notifications (→ v0.19.1, v0.19.2)

Notifications are split out because:

  1. Independent value. Projects are useful without notifications. Notifications are useful without projects. Neither blocks the other.

  2. Scope control. Notifications span persistence, real-time WebSocket delivery, email transport with SMTP, HTML templates, digest batching, and per-user preferences. That's a standalone feature set with its own store, handler, and UI.

  3. Email is ops-heavy. SMTP configuration, template rendering, and digest scheduling affect deployment requirements. Air-gapped envs won't have SMTP. Separating lets us ship in-app notifications first.

v0.19.1 — In-App Notifications:

  • notifications table + NotificationStore (both dialects)
  • In-app notification bell (header bar, unread count badge)
  • Notification dropdown: grouped by type, mark read, click-to-navigate
  • WebSocket push via EventBus (notification.newDirToClient in routeTable)
  • Sources: grant changes, KB processing complete, project invites, memory review queue (completes v0.18.0 admin workflow), team invites

v0.19.2 — Email Transport (tentative):

  • SMTP config in admin settings (host, port, from, TLS)
  • HTML + plaintext templates, branded with instance name
  • Per-user preferences: per-type toggles (in-app / email / off)
  • Digest mode: batch low-priority notifications (hourly/daily)
  • Admin enable/disable email globally or per-team

13. Forward Compatibility

13.1 Multi-Participant Channels (v0.23.0)

Projects don't assume single-owner channels. A project channel that later becomes a group channel (with channel_participants) works unchanged — project_channels joins on channel_id, not user_id.

13.2 Workflow Engine (v0.25.0)

Workflow channels (type='workflow') can belong to projects. A project could group all workflow instances for a process. The project_id on channels is type-agnostic.

13.3 Project-Scoped Memory (future)

Not implemented, but the data model supports it. If a project has a default Persona, memories from project channels naturally feed into persona-scoped memory via existing v0.18.0 mechanics. No schema changes.

13.4 Extension Surfaces (v0.21.0)

The project settings panel uses PanelRegistry (v0.18.1). When extension surfaces land, a project could define which surfaces/modes are available to its channels via settings JSON extension — no schema change.


14. Migration Safety

14.1 Postgres

Migration 006_v0190_projects.sql is additive:

  • New tables: projects, project_channels, project_knowledge_bases, project_notes
  • New column: channels.project_id (nullable FK, ON DELETE SET NULL)
  • Altered CHECK: resource_grants.resource_type adds 'project'
  • Dropped column: channels.folder_id (confirmed unused)

All operations are backward-compatible. Existing channels work without project assignment. The migration is safe to apply to live databases (no table locks beyond the brief ALTER TABLE).

14.2 SQLite

sqlite/006_v0190_projects.sql with standard dialect adaptations:

  • UUIDTEXT
  • TIMESTAMPTZTEXT
  • JSONBTEXT (JSON)
  • gen_random_uuid() → app-side store.NewID()
  • ALTER TABLE channels ADD COLUMN project_id TEXT (no FK enforcement in SQLite, but the store layer handles referential integrity)

Note: ALTER TABLE DROP COLUMN requires SQLite ≥ 3.35.0. Ubuntu 24 ships 3.45, so folder_id drop is safe. Verify minimum SQLite version in CI.

14.3 Rollback

All new tables can be dropped without affecting existing data. The channels.project_id column can be dropped (nullable, no data loss). The resource_grants CHECK can be reverted. No destructive operations on existing tables.

14.4 Folder Migration (best-effort)

For users with existing channels.folder values, the migration creates personal projects from distinct folder names:

-- Pseudo-SQL (actual implementation in Go for SQLite compat)
-- For each distinct (user_id, folder) pair where folder is non-empty:
--   1. Create a personal project with name = folder
--   2. Insert project_channels row
--   3. Set channels.project_id

Implementation is in the Go migration runner (not raw SQL) because SQLite needs store.NewID() for UUID generation. If no folder values exist, nothing happens. This is a convenience migration, not a correctness requirement — users can reorganize manually.


15. Test Plan

15.1 Store Integration Tests (both dialects)

  • Project CRUD: create, read, update, delete, list
  • Scope enforcement: personal projects invisible to other users, team projects visible to team members, global visible to all
  • Resource association: add/remove channels, KBs, notes
  • Cascade: project delete detaches channels (project_id → NULL)
  • Cascade: channel delete removes junction row, project unaffected
  • Cascade: KB delete removes junction row, project unaffected
  • GetProjectKBsForChannel: correct KBs through the full join chain (channel → project_channel → project_kb → kb)
  • GetProjectKBsForChannel: returns empty for unassigned channels
  • Grant resolution: UserCanAccess for team_only, global, group grants
  • ListAccessible: union of personal + team + granted
  • One-project-per-channel: UNIQUE constraint on project_channels.channel_id rejects duplicate assignment
  • MoveChannel: position and folder_path update correctly

15.2 Handler Integration Tests

  • CRUD with auth: admin creates global, team_admin creates team, user creates personal, unauthorized users rejected
  • Grant management: set/get grants, verify access changes
  • Channel filter: ?project_id=uuid returns only project channels, ?project_id=none returns only unassigned
  • KB injection: completion with project KBs includes them in search context alongside channel and persona KBs

15.3 Frontend Tests (manual + smoke)

  • Sidebar renders project groups with correct channels
  • Collapse/expand persists across page refresh
  • Drag channel into project updates sidebar and API
  • Drag channel between projects moves cleanly
  • Drag channel to "Recent" removes from project
  • New channel within project context auto-associates
  • Project settings panel opens with correct data
  • Project delete detaches channels (they appear in "Recent")

15.4 Unhappy Path / Defensive Tests

  • Corrupt JSON in channels.settingsscanJSON defaults to {}, warning logged, channel list still returns valid response
  • Corrupt JSON in channels.tagsscanTags defaults to [], warning logged
  • SafeJSON catches marshal failure → returns 500, not truncated 200
  • Startup integrity check detects corrupt rows and logs warnings
  • Channel moved between projects: old junction row removed, new one created, project_id denorm updated — all atomic
  • Project delete with channels: channels detach cleanly, appear in "Recent", no orphaned junction rows
  • KB removed from project: immediately stops appearing in completion context for project channels (virtual injection, no stale rows)
  • Non-existent project_id filter on channels → empty result, not error
  • Adding channel to project when channel already in another project → atomic move, not duplicate

16. Resolved Questions

  1. Should "New Chat" always prompt for project? No. New chats go to "Recent" by default. Users drag them into projects when worth keeping. Channels created from within a project context (expanded project group) auto-associate. Low-friction quick-chat UX preserved.

  2. Channel in multiple projects? One project per channel. Enforced by UNIQUE on project_channels.channel_id. Moving a channel between projects must be easy — the UI should support drag-to-different-project and a "Move to project" context menu action. The AddChannel store method handles the move atomically: remove from old project (if any), add to new, update denorm column.

  3. Project-level search? Deferred to v0.19.1 or v0.19.2. The junction tables make this straightforward — WHERE channel_id IN (SELECT channel_id FROM project_channels WHERE project_id = $1). Not MVP but real utility for power users with many project channels.

  4. Note auto-association? Yes. When a note is created from a message in a project channel, auto-associate it with the project via project_notes. The provenance chain (channel → project → note) is natural and expected. Implemented in the note creation handler by checking channel.project_id and inserting the junction row.


17. Bugfixes Bundled with v0.19.0

17.0 Defensive Coding: Unhappy Path Philosophy

Chat Switchboard needs to be resilient to dirty data, especially post-upgrade. Users (and developers) don't always back up before upgrading. Migrations can leave stale or corrupt data. External inputs (API keys, provider responses, pasted content) can inject unexpected bytes. The codebase should warn and continue rather than crash or produce truncated responses.

Principles:

  • Validate on read, not just write. Even if the write path ensures valid JSON, the read path must handle the case where it isn't. Migrations, manual DB edits, encoding bugs, and version skew can all produce invalid data.

  • Degrade gracefully. A corrupt settings column on one channel should not prevent loading all channels. Default to {} or [], log a warning with the row ID, and continue.

  • Never send 200 with a broken body. Pre-validate serialization for endpoints that touch user-controlled JSON columns. If marshaling fails, return 500 with a useful error, not a truncated stream.

  • Log actionable context. Warnings should include: table, column, row ID, and the nature of the corruption. This lets admins find and fix the specific row without guessing.

  • Apply everywhere JSON columns are scanned. The scanJSON and scanTags helpers are used across channels, settings, personas, and knowledge bases. The fix must be in the shared helpers, not patched per-handler.

17.1 Channels endpoint returns truncated JSON (P0)

Root cause found: A channel row has a corrupted settings column containing a \x02 (STX control byte) instead of valid JSON. The scanJSON helper copies raw bytes without validation — the scan succeeds. Then c.JSON(200, ...) starts writing the response (status

  • headers flushed), the JSON encoder hits the corrupt RawMessage, MarshalJSON() validates the content, finds \x02, aborts mid-stream. Truncated body. Browser gets "Unexpected end of JSON input".

Server log confirmation (fires on every channels request):

Error #01: json: error calling MarshalJSON for type json.RawMessage:
           invalid character '\x02' looking for beginning of value

Fix (three layers):

Layer 1 — Harden scanJSON (shared helper):

func (s *jsonScanner) Scan(src interface{}) error {
    switch v := src.(type) {
    case []byte:
        if !json.Valid(v) {
            log.Printf("⚠ scanJSON: invalid JSON in column (len=%d, preview=%.40q), defaulting to {}", len(v), v)
            *s.dest = json.RawMessage("{}")
            return nil
        }
        *s.dest = json.RawMessage(v)
    case string:
        b := []byte(v)
        if !json.Valid(b) {
            log.Printf("⚠ scanJSON: invalid JSON in column (len=%d, preview=%.40q), defaulting to {}", len(b), b)
            *s.dest = json.RawMessage("{}")
            return nil
        }
        *s.dest = json.RawMessage(b)
    case nil:
        *s.dest = json.RawMessage("{}")
    default:
        *s.dest = json.RawMessage("{}")
    }
    return nil
}

This ensures no corrupt data reaches the JSON encoder. The warning log includes a truncated preview to help identify the source without dumping the full corrupt blob.

Layer 2 — Same treatment for scanTags: Apply identical validation to the tags scanner. If the stored value isn't valid JSON array, default to [] and warn.

Layer 3 — Pre-marshal on list endpoints: For endpoints that return arrays of objects with json.RawMessage fields, pre-marshal the response to catch errors before headers are sent:

// Helper: SafeJSON writes status + body only after successful marshal
func SafeJSON(c *gin.Context, code int, obj interface{}) {
    body, err := json.Marshal(obj)
    if err != nil {
        log.Printf("⚠ SafeJSON: marshal failed: %v", err)
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "response serialization failed",
        })
        return
    }
    c.Data(code, "application/json; charset=utf-8", body)
}

Apply to: ListChannels, GetChannel, ListPersonas, and any other handler returning models with JSONB fields.

Layer 4 — Startup integrity check (warn-only): On application boot, after migrations complete, run a lightweight scan for corrupt JSON columns:

func checkJSONIntegrity(db *sql.DB) {
    tables := []struct{ table, column string }{
        {"channels", "settings"},
        {"channels", "tags"},
        {"personas", "settings"},
        {"knowledge_bases", "metadata"},
        {"users", "settings"},
    }
    for _, t := range tables {
        // Postgres: check for non-JSON values
        query := fmt.Sprintf(
            `SELECT id FROM %s WHERE %s IS NOT NULL
             AND %s::text != '' AND %s::text NOT SIMILAR TO '[{"[\s]%%'`,
            t.table, t.column, t.column, t.column)
        rows, err := db.Query(query)
        if err != nil { continue }
        var ids []string
        for rows.Next() {
            var id string
            rows.Scan(&id)
            ids = append(ids, id)
        }
        rows.Close()
        if len(ids) > 0 {
            log.Printf("⚠ Corrupt JSON detected: %s.%s in %d rows: %v",
                t.table, t.column, len(ids), ids)
        }
    }
}

This doesn't repair anything — just surfaces problems in the startup log so admins know. Repair is manual or via a future --repair flag.

17.2 Service Worker caches chrome-extension:// URLs

Symptom: TypeError: Failed to execute 'put' on 'Cache': Request scheme 'chrome-extension' is unsupported on every page load.

Root cause: The SW fetch handler's exclusion checks are all pathname-based (/api/, /ws, etc.). URLs from browser extensions pass through and hit Cache.put() which rejects non-http(s) schemes.

Fix: Scheme guard at the top of the fetch handler:

self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);
    if (url.protocol !== 'https:' && url.protocol !== 'http:') {
        return; // Ignore chrome-extension://, moz-extension://, etc.
    }
    // ... existing handler ...
});

17.3 Branding 404 on custom.css

Symptom: GET /branding/custom.css 404 on every page load when no custom branding is configured.

Fix: Serve an empty CSS file as the default when no custom branding is mounted. Either:

  • (a) Backend: static handler returns empty text/css body with Cache-Control: no-cache if file doesn't exist, or
  • (b) Frontend: conditionally emit the <link> tag based on a has_custom_branding flag in the health/settings response.

Option (a) is simpler — one handler change, no frontend coordination.

17.4 Duplicate PUT /settings during init

Symptom: Two PUT /api/v1/settings calls within ~50ms during startup. Confirmed in server logs — two PUTs at 21:21:23 and again at 21:21:36 (second page load).

Root cause: Two independent code paths saving settings on init.

Fix: Consolidate or debounce. A settingsDirty flag + 100ms setTimeout debounce in the API layer ensures only one PUT fires per init cycle.

17.5 WebSocket token in URL (minor security hygiene)

Observation from server logs: The WS endpoint logs include the full JWT in the URL query string:

GET "/test/ws?token=eyJhbG..."

This means the JWT appears in: Gin access logs, nginx access logs, and any log aggregator. Access tokens are short-lived (15 min) so the risk is low, but it's worth:

  • Redacting the token in Gin's log formatter (show ws?token=[REDACTED])
  • Or switching to WS subprotocol auth (send token as first message after connect) in a future release

Not blocking for v0.19.0 but worth noting.