Merge pull request 'Design and implement per-user tenancy' (#40) from issue-10-per-user-tenancy into main
CI / build-and-test (push) Successful in 1m40s

Reviewed-on: #40
This commit was merged in pull request #40.
This commit is contained in:
2026-07-15 10:48:24 +00:00
15 changed files with 907 additions and 52 deletions
+8
View File
@@ -18,6 +18,14 @@ jobs:
with:
go-version: '1.26'
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Enable pnpm
run: corepack enable
- name: Cache Go modules
uses: actions/cache@v4
with:
+161
View File
@@ -0,0 +1,161 @@
# Per-user tenancy implementation plan
## Scope and current state
Gitea issue #10 asks AMCS to isolate memories by user, tenant, or workspace. The current branch already carries the first implementation pass, so this plan records the intended model and the remaining hardening work another worker should verify or finish.
Observed code paths:
- Authentication enters through `internal/auth/middleware.go`. API-key auth uses the configured header, defaulting to `x-brain-key`; bearer tokens can resolve through OAuth `TokenStore` or API keyring; HTTP Basic resolves OAuth client credentials.
- `internal/auth/middleware.go` writes both `auth.key_id` and `tenancy.tenant_key` into the request context. The tenant key is intentionally opaque and currently equals the authenticated key id or OAuth client id.
- `internal/tenancy/tenancy.go` exposes `WithTenantKey` and `KeyFromContext` only; callers should not infer user semantics from the tenant string.
- Schema already has `tenant_key` on `projects`, `thoughts`, `stored_files`, `learnings`, `plans`, and `chat_histories` in `schema/*.dbml`.
- `internal/store/tenancy.go` provides helper functions for appending tenant predicates to SQL.
- Tenant scoping is already present in project, thought, and stored-file store paths. Some other project-owned domains still need explicit tenant enforcement.
## Tenant/user identity source
Use the authenticated principal id as the tenant boundary:
1. API key: resolve token through `auth.Keyring.Lookup`; use returned key id as `tenant_key`.
2. OAuth bearer token: resolve token through `TokenStore.Lookup`; use returned client id/key id as `tenant_key`.
3. OAuth Basic client credentials: resolve through `OAuthRegistry.Lookup`; use returned client id as `tenant_key`.
4. Unauthenticated requests must not get tenant context and must not reach protected MCP/API handlers.
Do not store raw API keys or bearer tokens in tenant columns. Store only stable configured key ids/client ids. Yes, it is less flashy than inventing an account service before breakfast, but it keeps the trust boundary small and auditable.
## Required schema/model changes
Source-of-truth DBML changes:
- Add nullable `tenant_key text` to all user-owned tables:
- `projects`
- `thoughts`
- `stored_files`
- `learnings`
- `plans`
- `chat_histories`
- Add tenant indexes:
- single-column `tenant_key` for direct filtering
- `(tenant_key, name)` unique on `projects`, replacing global `projects.name` uniqueness
- `(tenant_key, project_id)` on `thoughts` for common project-scoped memory lookups
- Regenerate SQL migrations and generated models from DBML; do not hand-edit generated Go models except as a temporary debugging step.
Important follow-up: `agent_skills`, `agent_guardrails`, `agent_personas`, `agent_parts`, traits, and arcs are currently global catalogs. Keep them global unless product requirements say skills/personas are private per tenant. Project join tables inherit protection through tenant-scoped project ids, but direct join queries must verify the project belongs to the tenant.
## Query scoping strategy
All request-facing store methods that touch tenant-owned rows must include tenant predicates whenever `tenancy.KeyFromContext(ctx)` returns a key.
Rules:
- Inserts must populate `tenant_key` from context.
- Gets/updates/deletes by id or guid must add `and tenant_key = $n`.
- Lists/searches must add `tenant_key = $n` before other filters.
- Joins must scope the tenant-owned root table. Example: project summaries should count only thoughts that belong to the same tenant as the project, not merely thoughts with the same `project_id`.
- Background maintenance jobs must either run per tenant, carry tenant context explicitly, or intentionally operate cross-tenant with an internal-only code path documented in the job.
Already-scoped paths to keep:
- `internal/store/projects.go`: create/get/list/touch projects.
- `internal/store/thoughts.go`: create/list/get/update/delete/archive/search/stats/embedding repair paths.
- `internal/store/files.go`: insert/get/list stored files.
Paths needing audit/hardening:
- `internal/store/learnings.go`: `CreateLearning`, `GetLearning`, and `ListLearnings` should populate/filter by tenant.
- `internal/store/plans.go`: `CreatePlan`, `GetPlan`, `GetPlanDetail`, `UpdatePlan`, `DeletePlan`, `ListPlans`, dependency/related-plan operations, and plan skill/guardrail joins should scope to tenant-owned plans.
- `internal/store/chat_histories.go`: chat history create/get/list/update paths should populate/filter by tenant.
- `internal/store/thought_learning_links.go`: links traverse tenant-owned thoughts/learnings; queries should join and scope both sides or at least the tenant-owned root.
- `internal/store/skills.go` and `internal/store/project_personas.go`: project-scoped joins should verify the project is visible to the current tenant before returning linked global catalog records.
- ResolveSpec/admin CRUD endpoints exposing generated models must not bypass tenant-aware store methods. If they use direct generic model access, add middleware-level filter injection or disable tenant-owned tables from generic admin writes.
## Migration and backfill
Migration approach for existing single-tenant installs:
1. Add nullable `tenant_key` columns first; do not make them `not null` initially because existing deployments have historical rows without a principal.
2. Backfill existing rows to a configured default tenant only if the instance enables multi-tenant mode or defines `auth.default_tenant_key`. Otherwise leave `NULL` rows visible only to unauthenticated/internal single-tenant contexts.
3. Replace global project-name uniqueness with `(tenant_key, name)` uniqueness. For PostgreSQL, preserve legacy `NULL` semantics carefully: multiple null-tenant projects with the same name may be possible unless a partial unique index is added for null tenant rows.
4. Add indexes concurrently where practical for production-sized tables.
5. Document that after enabling auth-backed tenancy, legacy null-tenant data is not visible to authenticated tenants unless backfilled.
Recommended config addition:
- `auth.default_tenant_key` or `tenancy.default_key` for one-time/self-hosted backfill and development.
- Optional `tenancy.mode: single|authenticated` so operators can keep current single-user behavior deliberately instead of discovering isolation by accident. An accident in auth is just a breach with better branding.
## Authorization checks
Authorization is row ownership by tenant key:
- A request may only see or mutate rows whose `tenant_key` equals the authenticated tenant key.
- Cross-tenant ids/gids should behave as not found, not forbidden, to avoid existence leaks.
- Tenant key is server-derived only. Ignore any client-provided `tenant_key` fields on tool/API inputs.
- Project id references in create/update operations must be validated against the same tenant before use. This prevents attaching a new thought/file/plan to another tenant's project id.
- Relationship operations must verify both endpoints are in the same tenant before creating links.
- Global catalogs can be read across tenants only if intentionally shared; project-specific associations must be tenant-checked through the project.
## Affected endpoints/services/UI surfaces
Backend/MCP tools:
- Project tools: create/get/list/set active project.
- Thought tools: capture, retrieve, update, delete/archive, semantic search, text search, stats, metadata and embedding repair queues.
- File tools and binary file upload/download APIs.
- Learning tools and thought-learning link tools.
- Plan/task tools including dependencies, related plans, skills, and guardrails.
- Chat history/session persistence tools.
- Persona/skill/guardrail project-association tools.
HTTP/API boundaries:
- MCP SSE and streamable HTTP handlers must run behind auth middleware when auth is configured.
- Any non-MCP REST endpoints under the admin/API server must either use tenant-aware stores or explicitly be internal/admin-only.
- ResolveSpec admin CRUD views need tenant filter injection or table-level access restrictions for tenant-owned models.
UI:
- Project selector/list should naturally show tenant-scoped projects.
- Admin tables for projects/thoughts/files/learnings/plans/chat histories must not show `tenant_key` as an editable field.
- If a tenant switcher is ever added, it must map to a server-side authenticated principal or admin impersonation path, not a client-side query parameter. Obviously.
## Test cases
Minimum test matrix:
1. Auth middleware propagates tenant key for API-key header auth.
2. Auth middleware propagates tenant key for bearer token via OAuth token store.
3. Auth middleware propagates tenant key for HTTP Basic OAuth client credentials.
4. Tenant A and tenant B can create projects with the same name; each only lists its own project.
5. Tenant A cannot get/update/delete/archive Tenant B's thought by guid or numeric id.
6. Semantic and text search return only same-tenant thoughts, including project-filtered searches.
7. Stored file get/list/download is scoped; a file id from another tenant returns not found.
8. Learning create/list/get is scoped, including links to thoughts.
9. Plan create/list/get/update/delete and dependency/related-plan operations are scoped.
10. Project skill/persona/guardrail association reads verify the project belongs to the tenant.
11. Background metadata/embedding retry queues do not cross tenants unless intentionally internal.
12. Migration test covers legacy null-tenant rows and backfill to default tenant.
13. ResolveSpec/admin API tests prove tenant-owned resources are filtered or unavailable without admin override.
## Assumptions and blockers
Assumptions:
- The first tenancy boundary is authenticated principal id, not human account, org, or workspace. This is consistent with the current auth system and avoids adding an account model prematurely.
- Null `tenant_key` remains the compatibility path for existing single-tenant/internal flows.
- Global skills/personas/guardrails remain shared catalogs until a separate product decision makes them tenant-private.
Blockers/decisions needed:
- Decide whether legacy rows should be backfilled automatically to a configured default tenant during migration or left null until an operator runs an explicit backfill.
- Decide whether ResolveSpec admin endpoints are trusted admin-only or must enforce tenant predicates like MCP tools.
- Decide whether future OAuth subjects should distinguish user id from client id; the current implementation only has client/key id available.
## Implementation order
1. Finish tenant scoping audits for learnings, plans, chat histories, thought-learning links, and project catalog joins.
2. Add tests proving cross-tenant invisibility for each store/tool domain before widening coverage. Yes, tests first; future us has enough enemies.
3. Add config/backfill migration behavior and document operator steps.
4. Lock down or filter ResolveSpec/admin tenant-owned model access.
5. Run `make test` and `make build`; include exact results in the issue/PR comment.
+10 -5
View File
@@ -11,6 +11,7 @@ import (
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/observability"
"git.warky.dev/wdevs/amcs/internal/requestip"
"git.warky.dev/wdevs/amcs/internal/tenancy"
)
type contextKey string
@@ -50,6 +51,10 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
)
}
}
withTenant := func(ctx context.Context, keyID string) context.Context {
ctx = context.WithValue(ctx, keyIDContextKey, keyID)
return tenancy.WithTenantKey(ctx, keyID)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remoteAddr := requestip.FromRequest(r)
@@ -63,7 +68,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
return
}
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
}
@@ -73,14 +78,14 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
if tokenStore != nil {
if keyID, ok := tokenStore.Lookup(bearer); ok {
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
}
if keyring != nil {
if keyID, ok := keyring.Lookup(bearer); ok {
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
}
@@ -103,7 +108,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
return
}
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
@@ -117,7 +122,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
return
}
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
}
+44
View File
@@ -0,0 +1,44 @@
package auth
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/tenancy"
)
func TestMiddlewareAddsTenantKeyToContext(t *testing.T) {
keyring, err := NewKeyring([]config.APIKey{{ID: "user-a", Value: "secret-a"}})
if err != nil {
t.Fatalf("NewKeyring error = %v", err)
}
var gotKeyID, gotTenant string
handler := Middleware(config.AuthConfig{}, keyring, nil, nil, nil, slog.Default())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ok bool
gotKeyID, ok = KeyIDFromContext(r.Context())
if !ok {
t.Fatal("KeyIDFromContext ok = false")
}
gotTenant, ok = tenancy.KeyFromContext(r.Context())
if !ok {
t.Fatal("tenancy.KeyFromContext ok = false")
}
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("x-brain-key", "secret-a")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNoContent)
}
if gotKeyID != "user-a" || gotTenant != "user-a" {
t.Fatalf("keyID=%q tenant=%q, want user-a/user-a", gotKeyID, gotTenant)
}
}
+6 -5
View File
@@ -15,10 +15,10 @@ import (
func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile) (thoughttypes.StoredFile, error) {
row := db.pool.QueryRow(ctx, `
insert into stored_files (thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
insert into stored_files (thought_id, project_id, tenant_key, name, media_type, kind, encoding, size_bytes, sha256, content)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
returning id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, created_at, updated_at
`, file.ThoughtID, file.ProjectID, file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
`, file.ThoughtID, file.ProjectID, tenantKeyPtr(ctx), file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
var model generatedmodels.ModelPublicStoredFiles
if err := row.Scan(
@@ -42,11 +42,11 @@ func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile
}
func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.StoredFile, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, `
select id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content, created_at, updated_at
from stored_files
where guid = $1
`, id)
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
var model generatedmodels.ModelPublicStoredFiles
if err := row.Scan(
@@ -77,6 +77,7 @@ func (db *DB) ListStoredFiles(ctx context.Context, filter thoughttypes.StoredFil
args := make([]any, 0, 4)
conditions := make([]string, 0, 3)
addTenantCondition(ctx, &args, &conditions, "tenant_key")
if filter.ThoughtID != nil {
args = append(args, *filter.ThoughtID)
conditions = append(conditions, fmt.Sprintf("thought_id = $%d", len(args)))
+17 -9
View File
@@ -14,10 +14,10 @@ import (
func (db *DB) CreateProject(ctx context.Context, name, description string) (thoughttypes.Project, error) {
row := db.pool.QueryRow(ctx, `
insert into projects (name, description)
values ($1, $2)
insert into projects (name, description, tenant_key)
values ($1, $2, $3)
returning id, guid, name, description, created_at, last_active_at
`, name, description)
`, name, description, tenantKeyPtr(ctx))
var model generatedmodels.ModelPublicProjects
if err := row.Scan(&model.ID, &model.GUID, &model.Name, &model.Description, &model.CreatedAt, &model.LastActiveAt); err != nil {
@@ -45,20 +45,20 @@ func (db *DB) GetProject(ctx context.Context, nameOrID string) (thoughttypes.Pro
}
func (db *DB) getProjectByGUID(ctx context.Context, id uuid.UUID) (thoughttypes.Project, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, `
select id, guid, name, description, created_at, last_active_at
from projects
where guid = $1
`, id)
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
return scanProject(row)
}
func (db *DB) getProjectByName(ctx context.Context, name string) (thoughttypes.Project, error) {
args := []any{name}
row := db.pool.QueryRow(ctx, `
select id, guid, name, description, created_at, last_active_at
from projects
where name = $1
`, name)
where name = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
return scanProject(row)
}
@@ -74,13 +74,20 @@ func scanProject(row pgx.Row) (thoughttypes.Project, error) {
}
func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary, error) {
args := []any{}
where := ""
if key, ok := tenantKey(ctx); ok {
args = append(args, key)
where = "where p.tenant_key = $1"
}
rows, err := db.pool.Query(ctx, `
select p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at, count(t.id) as thought_count
from projects p
left join thoughts t on t.project_id = p.id and t.archived_at is null
`+where+`
group by p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at
order by p.last_active_at desc, p.created_at desc
`)
`, args...)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
@@ -105,7 +112,8 @@ func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary,
}
func (db *DB) TouchProject(ctx context.Context, id int64) error {
tag, err := db.pool.Exec(ctx, `update projects set last_active_at = now() where id = $1`, id)
args := []any{id}
tag, err := db.pool.Exec(ctx, `update projects set last_active_at = now() where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
if err != nil {
return fmt.Errorf("touch project: %w", err)
}
+34
View File
@@ -0,0 +1,34 @@
package store
import (
"context"
"fmt"
"git.warky.dev/wdevs/amcs/internal/tenancy"
)
func tenantKeyPtr(ctx context.Context) *string {
if key, ok := tenancy.KeyFromContext(ctx); ok {
return &key
}
return nil
}
func tenantKey(ctx context.Context) (string, bool) {
return tenancy.KeyFromContext(ctx)
}
func addTenantCondition(ctx context.Context, args *[]any, conditions *[]string, column string) {
if key, ok := tenancy.KeyFromContext(ctx); ok {
*args = append(*args, key)
*conditions = append(*conditions, fmt.Sprintf("%s = $%d", column, len(*args)))
}
}
func tenantSQL(ctx context.Context, args *[]any, column string) string {
if key, ok := tenancy.KeyFromContext(ctx); ok {
*args = append(*args, key)
return fmt.Sprintf(" and %s = $%d", column, len(*args))
}
return ""
}
+26 -15
View File
@@ -31,10 +31,10 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
}()
row := tx.QueryRow(ctx, `
insert into thoughts (content, metadata, project_id)
values ($1, $2::jsonb, $3)
insert into thoughts (content, metadata, project_id, tenant_key)
values ($1, $2::jsonb, $3, $4)
returning id, guid, created_at, updated_at
`, thought.Content, metadata, thought.ProjectID)
`, thought.Content, metadata, thought.ProjectID, tenantKeyPtr(ctx))
created := thought
created.Embedding = nil
@@ -123,6 +123,7 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
args := make([]any, 0, 6)
conditions := []string{}
addTenantCondition(ctx, &args, &conditions, "tenant_key")
if !filter.IncludeArchived {
conditions = append(conditions, "archived_at is null")
}
@@ -186,11 +187,14 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
var total int
if err := db.pool.QueryRow(ctx, `select count(*) from thoughts where archived_at is null`).Scan(&total); err != nil {
statsArgs := []any{}
statsConditions := []string{"archived_at is null"}
addTenantCondition(ctx, &statsArgs, &statsConditions, "tenant_key")
if err := db.pool.QueryRow(ctx, `select count(*) from thoughts where `+strings.Join(statsConditions, " and "), statsArgs...).Scan(&total); err != nil {
return thoughttypes.ThoughtStats{}, fmt.Errorf("count thoughts: %w", err)
}
rows, err := db.pool.Query(ctx, `select metadata from thoughts where archived_at is null`)
rows, err := db.pool.Query(ctx, `select metadata from thoughts where `+strings.Join(statsConditions, " and "), statsArgs...)
if err != nil {
return thoughttypes.ThoughtStats{}, fmt.Errorf("query stats metadata: %w", err)
}
@@ -233,11 +237,11 @@ func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
}
func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Thought, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, `
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts
where guid = $1
`, id)
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
var model generatedmodels.ModelPublicThoughts
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
@@ -256,11 +260,11 @@ func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Though
}
func (db *DB) GetThoughtByID(ctx context.Context, id int64) (thoughttypes.Thought, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, `
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts
where id = $1
`, id)
where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
var model generatedmodels.ModelPublicThoughts
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
@@ -292,14 +296,14 @@ func (db *DB) UpdateThought(ctx context.Context, id uuid.UUID, content string, e
_ = tx.Rollback(ctx)
}()
args := []any{id, content, metadataBytes, projectID}
tag, err := tx.Exec(ctx, `
update thoughts
set content = $2,
metadata = $3::jsonb,
project_id = $4,
updated_at = now()
where guid = $1
`, id, content, metadataBytes, projectID)
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
if err != nil {
return thoughttypes.Thought{}, fmt.Errorf("update thought: %w", err)
}
@@ -333,12 +337,12 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
return thoughttypes.Thought{}, fmt.Errorf("marshal updated metadata: %w", err)
}
args := []any{id, metadataBytes}
tag, err := db.pool.Exec(ctx, `
update thoughts
set metadata = $2::jsonb,
updated_at = now()
where id = $1
`, id, metadataBytes)
where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
if err != nil {
return thoughttypes.Thought{}, fmt.Errorf("update thought metadata: %w", err)
}
@@ -350,7 +354,8 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
}
func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `delete from thoughts where guid = $1`, id)
args := []any{id}
tag, err := db.pool.Exec(ctx, `delete from thoughts where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
if err != nil {
return fmt.Errorf("delete thought: %w", err)
}
@@ -361,7 +366,8 @@ func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
}
func (db *DB) ArchiveThought(ctx context.Context, id uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `update thoughts set archived_at = now(), updated_at = now() where guid = $1`, id)
args := []any{id}
tag, err := db.pool.Exec(ctx, `update thoughts set archived_at = now(), updated_at = now() where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
if err != nil {
return fmt.Errorf("archive thought: %w", err)
}
@@ -440,6 +446,7 @@ func (db *DB) SearchSimilarThoughts(ctx context.Context, embedding []float32, em
"1 - (e.embedding <=> $1) > $2",
"e.model = $3",
}
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
if projectID != nil {
args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
@@ -488,6 +495,7 @@ func (db *DB) HasEmbeddingsForModel(ctx context.Context, model string, projectID
"e.model = $1",
"t.archived_at is null",
}
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
if projectID != nil {
args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
@@ -506,6 +514,7 @@ func (db *DB) HasEmbeddingsForModel(ctx context.Context, model string, projectID
func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
args := []any{model}
conditions := []string{"e.id is null"}
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
if !includeArchived {
conditions = append(conditions, "t.archived_at is null")
@@ -555,6 +564,7 @@ func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, li
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
args := make([]any, 0, 3)
conditions := make([]string, 0, 4)
addTenantCondition(ctx, &args, &conditions, "tenant_key")
if !includeArchived {
conditions = append(conditions, "archived_at is null")
@@ -624,6 +634,7 @@ func (db *DB) SearchThoughtsText(ctx context.Context, query string, limit int, p
"t.archived_at is null",
"(to_tsvector('simple', t.content) || to_tsvector('simple', coalesce(p.name, ''))) @@ websearch_to_tsquery('simple', $1)",
}
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
if projectID != nil {
args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
+30
View File
@@ -0,0 +1,30 @@
package tenancy
import (
"context"
"strings"
)
type contextKey string
const tenantKeyContextKey contextKey = "tenancy.tenant_key"
// WithTenantKey returns a context scoped to the authenticated tenant boundary.
// The tenant key is intentionally opaque; today it is the authenticated API key
// or OAuth client id, and callers should not interpret it as a human username.
func WithTenantKey(ctx context.Context, tenantKey string) context.Context {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" {
return ctx
}
return context.WithValue(ctx, tenantKeyContextKey, tenantKey)
}
func KeyFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
value, ok := ctx.Value(tenantKeyContextKey).(string)
value = strings.TrimSpace(value)
return value, ok && value != ""
}
+526 -17
View File
@@ -110,6 +110,13 @@ CREATE SEQUENCE IF NOT EXISTS public.identity_thought_links_id
START 1
CACHE 1;
CREATE SEQUENCE IF NOT EXISTS public.identity_thought_learning_links_id
INCREMENT 1
MINVALUE 1
MAXVALUE 9223372036854775807
START 1
CACHE 1;
CREATE SEQUENCE IF NOT EXISTS public.identity_embeddings_id
INCREMENT 1
MINVALUE 1
@@ -332,6 +339,7 @@ CREATE TABLE IF NOT EXISTS public.thoughts (
id bigserial NOT NULL,
metadata jsonb DEFAULT '{}'::jsonb,
project_id bigint,
tenant_key text,
updated_at timestamptz DEFAULT now()
);
@@ -341,7 +349,8 @@ CREATE TABLE IF NOT EXISTS public.projects (
guid uuid NOT NULL DEFAULT gen_random_uuid(),
id bigserial NOT NULL,
last_active_at timestamptz DEFAULT now(),
name text NOT NULL
name text NOT NULL,
tenant_key text
);
CREATE TABLE IF NOT EXISTS public.thought_links (
@@ -352,6 +361,14 @@ CREATE TABLE IF NOT EXISTS public.thought_links (
to_id bigint NOT NULL
);
CREATE TABLE IF NOT EXISTS public.thought_learning_links (
created_at timestamptz NOT NULL DEFAULT now(),
id bigserial NOT NULL,
learning_id bigint NOT NULL,
relation text NOT NULL DEFAULT 'source',
thought_id bigint NOT NULL
);
CREATE TABLE IF NOT EXISTS public.embeddings (
created_at timestamptz DEFAULT now(),
dim integer NOT NULL,
@@ -375,6 +392,7 @@ CREATE TABLE IF NOT EXISTS public.stored_files (
project_id bigint,
sha256 text NOT NULL,
size_bytes bigint NOT NULL,
tenant_key text,
thought_id bigint,
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -390,6 +408,7 @@ CREATE TABLE IF NOT EXISTS public.chat_histories (
project_id bigint,
session_id text NOT NULL,
summary text,
tenant_key text,
title text,
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -424,6 +443,7 @@ CREATE TABLE IF NOT EXISTS public.learnings (
summary text NOT NULL,
supersedes_learning_id bigint,
tags text[] NOT NULL DEFAULT '{}',
tenant_key text,
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -450,6 +470,7 @@ CREATE TABLE IF NOT EXISTS public.plans (
status text NOT NULL DEFAULT 'draft',
supersedes_plan_id bigint,
tags text[] NOT NULL DEFAULT '{}',
tenant_key text,
title text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -1552,6 +1573,19 @@ BEGIN
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'thoughts'
AND column_name = 'tenant_key'
) THEN
ALTER TABLE public.thoughts ADD COLUMN tenant_key text;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -1643,6 +1677,19 @@ BEGIN
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'projects'
AND column_name = 'tenant_key'
) THEN
ALTER TABLE public.projects ADD COLUMN tenant_key text;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -1708,6 +1755,71 @@ BEGIN
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'thought_learning_links'
AND column_name = 'created_at'
) THEN
ALTER TABLE public.thought_learning_links ADD COLUMN created_at timestamptz NOT NULL DEFAULT now();
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'thought_learning_links'
AND column_name = 'id'
) THEN
ALTER TABLE public.thought_learning_links ADD COLUMN id bigserial NOT NULL;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'thought_learning_links'
AND column_name = 'learning_id'
) THEN
ALTER TABLE public.thought_learning_links ADD COLUMN learning_id bigint NOT NULL;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'thought_learning_links'
AND column_name = 'relation'
) THEN
ALTER TABLE public.thought_learning_links ADD COLUMN relation text NOT NULL DEFAULT 'source';
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'thought_learning_links'
AND column_name = 'thought_id'
) THEN
ALTER TABLE public.thought_learning_links ADD COLUMN thought_id bigint NOT NULL;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -1955,6 +2067,19 @@ BEGIN
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'stored_files'
AND column_name = 'tenant_key'
) THEN
ALTER TABLE public.stored_files ADD COLUMN tenant_key text;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -2111,6 +2236,19 @@ BEGIN
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'chat_histories'
AND column_name = 'tenant_key'
) THEN
ALTER TABLE public.chat_histories ADD COLUMN tenant_key text;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -2475,6 +2613,19 @@ BEGIN
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'learnings'
AND column_name = 'tenant_key'
) THEN
ALTER TABLE public.learnings ADD COLUMN tenant_key text;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -2735,6 +2886,19 @@ BEGIN
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'plans'
AND column_name = 'tenant_key'
) THEN
ALTER TABLE public.plans ADD COLUMN tenant_key text;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -5177,6 +5341,29 @@ BEGIN
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'thoughts'
AND a.attname = 'tenant_key'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['text']) THEN
ALTER TABLE public.thoughts
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
@@ -5338,6 +5525,29 @@ BEGIN
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'projects'
AND a.attname = 'tenant_key'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['text']) THEN
ALTER TABLE public.projects
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
@@ -5453,6 +5663,121 @@ BEGIN
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'thought_learning_links'
AND a.attname = 'created_at'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['timestamptz', 'timestamp with time zone']) THEN
ALTER TABLE public.thought_learning_links
ALTER COLUMN created_at TYPE timestamptz USING created_at::timestamptz;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'thought_learning_links'
AND a.attname = 'id'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['bigint']) THEN
ALTER TABLE public.thought_learning_links
ALTER COLUMN id TYPE bigint USING id::bigint;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'thought_learning_links'
AND a.attname = 'learning_id'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['bigint']) THEN
ALTER TABLE public.thought_learning_links
ALTER COLUMN learning_id TYPE bigint USING learning_id::bigint;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'thought_learning_links'
AND a.attname = 'relation'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['text']) THEN
ALTER TABLE public.thought_learning_links
ALTER COLUMN relation TYPE text USING relation::text;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'thought_learning_links'
AND a.attname = 'thought_id'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['bigint']) THEN
ALTER TABLE public.thought_learning_links
ALTER COLUMN thought_id TYPE bigint USING thought_id::bigint;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
@@ -5890,6 +6215,29 @@ BEGIN
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'stored_files'
AND a.attname = 'tenant_key'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['text']) THEN
ALTER TABLE public.stored_files
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
@@ -6166,6 +6514,29 @@ BEGIN
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'chat_histories'
AND a.attname = 'tenant_key'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['text']) THEN
ALTER TABLE public.chat_histories
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
@@ -6810,6 +7181,29 @@ BEGIN
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'learnings'
AND a.attname = 'tenant_key'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['text']) THEN
ALTER TABLE public.learnings
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
@@ -7270,6 +7664,29 @@ BEGIN
END;
$$;
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'plans'
AND a.attname = 'tenant_key'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY['text']) THEN
ALTER TABLE public.plans
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
END IF;
END;
$$;
DO $$
DECLARE
current_type text;
@@ -9035,6 +9452,50 @@ BEGIN
END;
$$;
DO $$
DECLARE
current_pk_name text;
current_pk_matches boolean := false;
BEGIN
SELECT tc.constraint_name,
COALESCE(
ARRAY(
SELECT a.attname::text
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN unnest(c.conkey) WITH ORDINALITY AS cols(attnum, ord)
ON TRUE
JOIN pg_attribute a
ON a.attrelid = t.oid
AND a.attnum = cols.attnum
WHERE c.contype = 'p'
AND n.nspname = 'public'
AND t.relname = 'thought_learning_links'
ORDER BY cols.ord
),
ARRAY[]::text[]
) = ARRAY['id']
INTO current_pk_name, current_pk_matches
FROM information_schema.table_constraints tc
WHERE tc.table_schema = 'public'
AND tc.table_name = 'thought_learning_links'
AND tc.constraint_type = 'PRIMARY KEY';
IF current_pk_name IS NOT NULL
AND NOT current_pk_matches
AND current_pk_name IN ('thought_learning_links_pkey', 'public_thought_learning_links_pkey') THEN
EXECUTE 'ALTER TABLE public.thought_learning_links DROP CONSTRAINT ' || quote_ident(current_pk_name) || ' CASCADE';
END IF;
-- Add the desired primary key only when no matching primary key already exists.
IF current_pk_name IS NULL
OR (NOT current_pk_matches AND current_pk_name IN ('thought_learning_links_pkey', 'public_thought_learning_links_pkey')) THEN
ALTER TABLE public.thought_learning_links ADD CONSTRAINT pk_public_thought_learning_links PRIMARY KEY (id);
END IF;
END;
$$;
DO $$
DECLARE
current_pk_name text;
@@ -9708,9 +10169,18 @@ CREATE INDEX IF NOT EXISTS idx_project_personas_project_id_persona_id
CREATE INDEX IF NOT EXISTS idx_arc_stage_parts_stage_id_part_id
ON public.arc_stage_parts USING btree (stage_id, part_id);
CREATE INDEX IF NOT EXISTS idx_thoughts_tenant_key_project_id
ON public.thoughts USING btree (tenant_key, project_id);
CREATE UNIQUE INDEX IF NOT EXISTS uidx_projects_tenant_key_name
ON public.projects USING btree (tenant_key, name);
CREATE INDEX IF NOT EXISTS idx_thought_links_from_id_to_id_relation
ON public.thought_links USING btree (from_id, to_id, relation);
CREATE UNIQUE INDEX IF NOT EXISTS uidx_thought_learning_links_thought_id_learning_id
ON public.thought_learning_links USING btree (thought_id, learning_id);
CREATE UNIQUE INDEX IF NOT EXISTS uidx_embeddings_thought_id_model
ON public.embeddings USING btree (thought_id, model);
@@ -9865,19 +10335,6 @@ BEGIN
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'projects'
AND constraint_name = 'ukey_projects_name'
) THEN
ALTER TABLE public.projects ADD CONSTRAINT ukey_projects_name UNIQUE (name);
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -10328,6 +10785,38 @@ BEGIN
END IF;
END;
$$;DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'thought_learning_links'
AND constraint_name = 'fk_thought_learning_links_learning_id'
) THEN
ALTER TABLE public.thought_learning_links
ADD CONSTRAINT fk_thought_learning_links_learning_id
FOREIGN KEY (learning_id)
REFERENCES public.learnings (id)
ON DELETE NO ACTION
ON UPDATE NO ACTION;
END IF;
END;
$$;DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'thought_learning_links'
AND constraint_name = 'fk_thought_learning_links_thought_id'
) THEN
ALTER TABLE public.thought_learning_links
ADD CONSTRAINT fk_thought_learning_links_thought_id
FOREIGN KEY (thought_id)
REFERENCES public.thoughts (id)
ON DELETE NO ACTION
ON UPDATE NO ACTION;
END IF;
END;
$$;DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
@@ -10912,15 +11401,15 @@ BEGIN
IF EXISTS (
SELECT 1 FROM pg_class c
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'identity_persona_arc_id'
WHERE c.relname = 'identity_persona_arc_persona_id'
AND n.nspname = 'public'
AND c.relkind = 'S'
) THEN
SELECT COALESCE(MAX(id), 0) + 1
SELECT COALESCE(MAX(persona_id), 0) + 1
FROM public.persona_arc
INTO m_cnt;
PERFORM setval('public.identity_persona_arc_id'::regclass, m_cnt);
PERFORM setval('public.identity_persona_arc_persona_id'::regclass, m_cnt);
END IF;
END;
$$;
@@ -10982,6 +11471,25 @@ BEGIN
END;
$$;
DO $$
DECLARE
m_cnt bigint;
BEGIN
IF EXISTS (
SELECT 1 FROM pg_class c
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'identity_thought_learning_links_id'
AND n.nspname = 'public'
AND c.relkind = 'S'
) THEN
SELECT COALESCE(MAX(id), 0) + 1
FROM public.thought_learning_links
INTO m_cnt;
PERFORM setval('public.identity_thought_learning_links_id'::regclass, m_cnt);
END IF;
END;
$$;
DO $$
DECLARE
m_cnt bigint;
BEGIN
@@ -11295,5 +11803,6 @@ $$;
+24
View File
@@ -0,0 +1,24 @@
-- Per-user tenancy: authenticate key/client id becomes an opaque tenant boundary.
-- Existing rows remain in the legacy unscoped tenant (NULL) for single-tenant installs.
alter table projects add column if not exists tenant_key text;
alter table thoughts add column if not exists tenant_key text;
alter table stored_files add column if not exists tenant_key text;
alter table learnings add column if not exists tenant_key text;
alter table plans add column if not exists tenant_key text;
alter table chat_histories add column if not exists tenant_key text;
-- Project names are now unique inside a tenant rather than globally.
alter table projects drop constraint if exists ukey_projects_name;
alter table projects drop constraint if exists projects_name_key;
drop index if exists projects_name_key;
create unique index if not exists projects_tenant_key_name_idx
on projects (coalesce(tenant_key, ''), name);
create index if not exists projects_tenant_key_idx on projects (tenant_key);
create index if not exists thoughts_tenant_key_idx on thoughts (tenant_key);
create index if not exists thoughts_tenant_key_project_id_idx on thoughts (tenant_key, project_id);
create index if not exists stored_files_tenant_key_idx on stored_files (tenant_key);
create index if not exists learnings_tenant_key_idx on learnings (tenant_key);
create index if not exists plans_tenant_key_idx on plans (tenant_key);
create index if not exists chat_histories_tenant_key_idx on chat_histories (tenant_key);
+13 -1
View File
@@ -6,16 +6,28 @@ Table thoughts {
created_at timestamptz [default: `now()`]
updated_at timestamptz [default: `now()`]
project_id bigint [ref: > projects.id]
tenant_key text
archived_at timestamptz
indexes {
tenant_key
(tenant_key, project_id)
}
}
Table projects {
id bigserial [pk]
guid uuid [unique, not null, default: `gen_random_uuid()`]
name text [unique, not null]
name text [not null]
description text
tenant_key text
created_at timestamptz [default: `now()`]
last_active_at timestamptz [default: `now()`]
indexes {
(tenant_key, name) [unique]
tenant_key
}
}
Table thought_links {
+2
View File
@@ -3,6 +3,7 @@ Table stored_files {
guid uuid [unique, not null, default: `gen_random_uuid()`]
thought_id bigint [ref: > thoughts.id]
project_id bigint [ref: > projects.id]
tenant_key text
name text [not null]
media_type text [not null]
kind text [not null, default: 'file']
@@ -16,6 +17,7 @@ Table stored_files {
indexes {
thought_id
project_id
tenant_key
sha256
}
}
+4
View File
@@ -6,6 +6,7 @@ Table chat_histories {
channel text
agent_id text
project_id bigint [ref: > projects.id]
tenant_key text
messages jsonb [not null, default: `'[]'`]
summary text
metadata jsonb [not null, default: `'{}'`]
@@ -15,6 +16,7 @@ Table chat_histories {
indexes {
session_id
project_id
tenant_key
channel
agent_id
created_at
@@ -46,6 +48,7 @@ Table learnings {
source_type text
source_ref text
project_id bigint [ref: > projects.id]
tenant_key text
related_thought_id bigint [ref: > thoughts.id]
related_skill_id bigint [ref: > agent_skills.id]
reviewed_by text
@@ -58,6 +61,7 @@ Table learnings {
indexes {
project_id
tenant_key
category
area
status
+2
View File
@@ -6,6 +6,7 @@ Table plans {
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
priority text [not null, default: 'medium'] // low, medium, high, critical
project_id bigint [ref: > projects.id]
tenant_key text
owner text
due_date timestamptz
completed_at timestamptz
@@ -18,6 +19,7 @@ Table plans {
indexes {
project_id
tenant_key
status
priority
owner