# DESIGN-0.16.0 — User Groups + Resource Grants ## Overview The missing access-control primitive. Teams define organizational structure (horizontal visibility). Roles define vertical permissions (admin/user). **Groups** are pure access-control lists that decouple resource access from team membership. Today, resource visibility follows a rigid scope model: `global` (everyone), `team` (team members), `personal` (owner only). This breaks down when: - A KB should be visible to members from two different teams. - A Persona should be accessible by a cross-functional working group. - A future Project needs participants from multiple teams. Groups solve this by adding a fourth access path — grant-by-group — without changing the existing scope model. Existing `global/team/personal` access continues to work unchanged. Groups are additive. Depends on: teams (v0.10.0), knowledge bases (v0.14.0). **Design principle: don't break the scope model.** Groups extend it. Every existing query that filters by `scope + owner_id + team_id` remains valid. Group membership is a parallel access path checked alongside scope, not a replacement for it. --- ## 1. Schema Migration: `010_groups.sql` ### 1.1 Groups Table ```sql -- ========================================= -- USER GROUPS -- ========================================= CREATE TABLE IF NOT EXISTS groups ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(100) NOT NULL, description TEXT NOT NULL DEFAULT '', scope VARCHAR(20) NOT NULL DEFAULT 'global' CHECK (scope IN ('global', 'team')), team_id UUID REFERENCES teams(id) ON DELETE CASCADE, created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), -- Global groups: team_id must be NULL -- Team groups: team_id must be set CONSTRAINT groups_scope_team CHECK ( (scope = 'global' AND team_id IS NULL) OR (scope = 'team' AND team_id IS NOT NULL) ) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000')); -- Unique name within scope (global names unique globally, -- team names unique within team) COMMENT ON TABLE groups IS 'Access-control groups. Decouple resource visibility from team membership.'; COMMENT ON COLUMN groups.scope IS 'global: admin-managed, can span teams. team: team-admin-managed, team-internal.'; ``` ### 1.2 Group Members Table ```sql CREATE TABLE IF NOT EXISTS group_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, added_by UUID NOT NULL REFERENCES users(id), added_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(group_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id); CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id); ``` ### 1.3 Resource Grants Table ```sql -- ========================================= -- RESOURCE GRANTS -- ========================================= CREATE TABLE IF NOT EXISTS resource_grants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), resource_type VARCHAR(30) NOT NULL CHECK (resource_type IN ('persona', 'knowledge_base')), resource_id UUID NOT NULL, grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only' CHECK (grant_scope IN ('team_only', 'global', 'groups')), granted_groups UUID[] NOT NULL DEFAULT '{}', created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), -- Only one grant row per resource UNIQUE(resource_type, resource_id) ); CREATE INDEX IF NOT EXISTS idx_resource_grants_resource ON resource_grants(resource_type, resource_id); CREATE INDEX IF NOT EXISTS idx_resource_grants_groups ON resource_grants USING gin(granted_groups); -- GIN index for UUID[] containment queries (&&, @>) COMMENT ON TABLE resource_grants IS 'Controls cross-team access to resources (personas, KBs, future: projects).'; COMMENT ON COLUMN resource_grants.grant_scope IS 'team_only: existing team scope behavior. global: all authenticated users. groups: specific group list.'; COMMENT ON COLUMN resource_grants.granted_groups IS 'UUID array of group IDs. Only meaningful when grant_scope = groups.'; ``` ### 1.4 Triggers ```sql -- Auto-update updated_at CREATE TRIGGER groups_updated_at BEFORE UPDATE ON groups FOR EACH ROW EXECUTE FUNCTION update_updated_at(); CREATE TRIGGER resource_grants_updated_at BEFORE UPDATE ON resource_grants FOR EACH ROW EXECUTE FUNCTION update_updated_at(); ``` ### Design Decision: UUID[] vs Junction Table The roadmap specifies `granted_groups UUID[]` on `resource_grants`. This is the right call for this use case: - A resource has exactly one grant row (UNIQUE constraint). - The group list is always read/written as a unit (replace-all semantics). - Postgres `UUID[] && ARRAY[...]::uuid[]` with a GIN index is fast for "does this resource grant overlap with the user's groups?" queries. - Avoids a many-to-many junction table that would complicate the common path (checking access) for marginal normalization benefit. A junction table (`resource_grant_groups`) would be warranted if we needed per-group config on grants (e.g., read-only vs read-write per group). We don't — the grant is binary (access or no access). If that changes, we can migrate the array to a junction table later. --- ## 2. Models ### 2.1 Group Models ```go // ── Group Constants ──────────────────────── const ( GroupScopeGlobal = "global" GroupScopeTeam = "team" ) // ── Groups ───────────────────────────────── type Group struct { ID string `json:"id" db:"id"` Name string `json:"name" db:"name"` Description string `json:"description" db:"description"` Scope string `json:"scope" db:"scope"` TeamID *string `json:"team_id,omitempty" db:"team_id"` CreatedBy string `json:"created_by" db:"created_by"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` // Computed fields (not stored in groups table) MemberCount int `json:"member_count,omitempty"` TeamName string `json:"team_name,omitempty"` } type GroupPatch struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` } type GroupMember struct { ID string `json:"id" db:"id"` GroupID string `json:"group_id" db:"group_id"` UserID string `json:"user_id" db:"user_id"` AddedBy string `json:"added_by" db:"added_by"` AddedAt string `json:"added_at" db:"added_at"` // Joined from users table Username string `json:"username,omitempty"` DisplayName string `json:"display_name,omitempty"` Email string `json:"email,omitempty"` } ``` ### 2.2 Resource Grant Models ```go // ── Resource Grant Constants ─────────────── const ( ResourceTypePersona = "persona" ResourceTypeKnowledgeBase = "knowledge_base" // Future: "project" GrantScopeTeamOnly = "team_only" GrantScopeGlobal = "global" GrantScopeGroups = "groups" ) // ResourceGrant controls cross-team access to a resource. type ResourceGrant struct { ID string `json:"id" db:"id"` ResourceType string `json:"resource_type" db:"resource_type"` ResourceID string `json:"resource_id" db:"resource_id"` GrantScope string `json:"grant_scope" db:"grant_scope"` GrantedGroups []string `json:"granted_groups" db:"granted_groups"` // UUID[] CreatedBy string `json:"created_by" db:"created_by"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } ``` --- ## 3. Store Interfaces ### 3.1 GroupStore ```go // ========================================= // GROUP STORE // ========================================= type GroupStore interface { // CRUD Create(ctx context.Context, g *models.Group) error GetByID(ctx context.Context, id string) (*models.Group, error) Update(ctx context.Context, id string, patch models.GroupPatch) error Delete(ctx context.Context, id string) error // Listing ListAll(ctx context.Context) ([]models.Group, error) // admin view ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) ListForUser(ctx context.Context, userID string) ([]models.Group, error) // groups I'm in // Members AddMember(ctx context.Context, groupID, userID, addedBy string) error RemoveMember(ctx context.Context, groupID, userID string) error ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) IsMember(ctx context.Context, groupID, userID string) (bool, error) // Bulk lookups (for access resolution) GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) } ``` ### 3.2 ResourceGrantStore ```go // ========================================= // RESOURCE GRANT STORE // ========================================= type ResourceGrantStore interface { // Upsert: one grant row per resource (replace semantics) Set(ctx context.Context, grant *models.ResourceGrant) error Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error) Delete(ctx context.Context, resourceType, resourceID string) error // Access check: does user have group-based access to this resource? UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) } ``` ### 3.3 Wire into Stores Bundle ```go type Stores struct { // ... existing fields ... Groups GroupStore ResourceGrants ResourceGrantStore } ``` --- ## 4. Access Resolution ### 4.1 The Problem Today, access checks look like this (Persona example): ```sql -- PersonaStore.ListForUser WHERE scope = 'global' OR (scope = 'personal' AND created_by = $1) OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $1)) OR (scope = 'personal' AND is_shared = true) ``` KB access follows the same pattern in `KnowledgeBaseStore.ListForUser` and the handler's `userCanAccess()` method. Groups add a fourth access path: "I'm in a group that has been granted access to this resource." ### 4.2 Design: Parallel Check, Not Query Rewrite We could embed the group check into every `ListForUser` query. That would mean every listing query joins against `resource_grants` and `group_members`, adding complexity to every scope-aware query in the system. Instead: **keep existing scope queries as-is, add group-granted resources as a separate UNION.** This is cleaner, backward-compatible, and doesn't slow down the common path for users without group-based access. ### 4.3 Updated Persona Listing ```go // PersonaStore.ListForUser — updated func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) { rows, err := DB.QueryContext(ctx, fmt.Sprintf(` -- Existing scope-based access (unchanged) SELECT %s FROM personas WHERE is_active = true AND ( scope = 'global' OR (scope = 'personal' AND created_by = $1) OR (scope = 'team' AND owner_id IN ( SELECT team_id FROM team_members WHERE user_id = $1 )) OR (scope = 'personal' AND is_shared = true) ) UNION -- Group-based access (new) SELECT %s FROM personas p WHERE p.is_active = true AND p.id IN ( SELECT rg.resource_id FROM resource_grants rg WHERE rg.resource_type = 'persona' AND ( rg.grant_scope = 'global' OR (rg.grant_scope = 'groups' AND rg.granted_groups && ( SELECT ARRAY_AGG(gm.group_id) FROM group_members gm WHERE gm.user_id = $1 )::uuid[]) ) ) ORDER BY scope, name `, personaCols, personaCols), userID) // ... } ``` The `&&` operator is the Postgres array overlap operator — returns true if the two arrays share any element. The GIN index on `granted_groups` makes this efficient. ### 4.4 Updated KB Listing Same pattern applied to `KnowledgeBaseStore.ListForUser`: ```go func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) { // ... existing scope-based query ... // Add UNION for group-granted KBs q += ` UNION SELECT id, name, description, scope, owner_id, team_id, embedding_config, document_count, chunk_count, total_bytes, status, created_at, updated_at FROM knowledge_bases kb WHERE kb.id IN ( SELECT rg.resource_id FROM resource_grants rg WHERE rg.resource_type = 'knowledge_base' AND ( rg.grant_scope = 'global' OR (rg.grant_scope = 'groups' AND rg.granted_groups && ( SELECT ARRAY_AGG(gm.group_id) FROM group_members gm WHERE gm.user_id = $1 )::uuid[]) ) )` q += ` ORDER BY name` return queryKBs(ctx, q, args...) } ``` ### 4.5 Updated UserCanAccess The KB handler's `userCanAccess()` method also needs updating: ```go func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool { // Existing scope-based check (unchanged) switch kb.Scope { case "global": return true case "personal": if kb.OwnerID != nil && *kb.OwnerID == userID { return true } case "team": if kb.TeamID != nil { for _, tid := range teamIDs { if tid == *kb.TeamID { return true } } } } // New: check group-based grants hasAccess, _ := h.stores.ResourceGrants.UserHasGroupAccess( context.Background(), userID, models.ResourceTypeKnowledgeBase, kb.ID) return hasAccess } ``` The `PersonaStore.UserCanAccess` method gets the same treatment. ### 4.6 GetActiveKBIDs (Channel KB Access) The `GetActiveKBIDs` query (used by the completion handler to determine which KBs to search) also needs the group path: ```sql -- Add to the WHERE clause: OR kb.id IN ( SELECT rg.resource_id FROM resource_grants rg WHERE rg.resource_type = 'knowledge_base' AND ( rg.grant_scope = 'global' OR (rg.grant_scope = 'groups' AND rg.granted_groups && ( SELECT ARRAY_AGG(gm.group_id) FROM group_members gm WHERE gm.user_id = $2 )::uuid[]) ) ) ``` ### 4.7 Performance Note: User Group IDs Cache The `ARRAY_AGG(gm.group_id)` subquery appears in every access check. For hot paths (listing, access checks), we can pre-fetch the user's group IDs once per request and pass them through: ```go // In middleware or handler setup: groupIDs, _ := stores.Groups.GetUserGroupIDs(ctx, userID) // Then in queries, pass as a parameter instead of subquery: // ... AND rg.granted_groups && $N::uuid[] ``` This trades a subquery for a pre-fetched array parameter. Worth doing in phase 2 if access checks show up in profiling. For v0.16.0, the subquery approach is simpler and correct. --- ## 5. Handlers ### 5.1 GroupHandler ```go type GroupHandler struct { stores store.Stores } func NewGroupHandler(s store.Stores) *GroupHandler { return &GroupHandler{stores: s} } ``` **Admin endpoints (global groups):** | Method | Path | Handler | Auth | |--------|------|---------|------| | GET | `/admin/groups` | ListAllGroups | admin | | POST | `/admin/groups` | CreateGlobalGroup | admin | | PUT | `/admin/groups/:id` | UpdateGroup | admin | | DELETE | `/admin/groups/:id` | DeleteGroup | admin | | GET | `/admin/groups/:id/members` | ListGroupMembers | admin | | POST | `/admin/groups/:id/members` | AddGroupMember | admin | | DELETE | `/admin/groups/:id/members/:userId` | RemoveGroupMember | admin | **Team endpoints (team-scoped groups):** | Method | Path | Handler | Auth | |--------|------|---------|------| | GET | `/teams/:teamId/groups` | ListTeamGroups | team member | | POST | `/teams/:teamId/groups` | CreateTeamGroup | team admin | | PUT | `/teams/:teamId/groups/:id` | UpdateTeamGroup | team admin | | DELETE | `/teams/:teamId/groups/:id` | DeleteTeamGroup | team admin | | GET | `/teams/:teamId/groups/:id/members` | ListTeamGroupMembers | team member | | POST | `/teams/:teamId/groups/:id/members` | AddTeamGroupMember | team admin | | DELETE | `/teams/:teamId/groups/:id/members/:userId` | RemoveTeamGroupMember | team admin | **User endpoint:** | Method | Path | Handler | Auth | |--------|------|---------|------| | GET | `/groups/mine` | MyGroups | authenticated | ### 5.2 Resource Grant Endpoints Grant management is inline on the resource endpoints, not a separate handler. **On Personas:** | Method | Path | Handler | Auth | |--------|------|---------|------| | GET | `/presets/:id/grants` | GetPersonaGrants | owner or admin | | PUT | `/presets/:id/grants` | SetPersonaGrants | owner or admin | **On Knowledge Bases:** | Method | Path | Handler | Auth | |--------|------|---------|------| | GET | `/knowledge-bases/:id/grants` | GetKBGrants | owner or admin | | PUT | `/knowledge-bases/:id/grants` | SetKBGrants | owner or admin | **Request/response format:** ```json // PUT /api/v1/presets/:id/grants { "grant_scope": "groups", "granted_groups": ["uuid-1", "uuid-2"] } // GET /api/v1/presets/:id/grants { "grant_scope": "groups", "granted_groups": [ { "id": "uuid-1", "name": "Engineering" }, { "id": "uuid-2", "name": "Design" } ] } ``` The GET response enriches group UUIDs with names for display. The PUT accepts bare UUIDs. --- ## 6. Routes (main.go) ```go // ── Groups ────────────────────────────── // User: my groups protected.GET("/groups/mine", groupH.MyGroups) // Team-scoped groups teamScoped.GET("/groups", groupH.ListTeamGroups) teamScoped.POST("/groups", groupH.CreateTeamGroup) // RequireTeamAdmin teamScoped.PUT("/groups/:groupId", groupH.UpdateTeamGroup) // RequireTeamAdmin teamScoped.DELETE("/groups/:groupId", groupH.DeleteTeamGroup) // RequireTeamAdmin teamScoped.GET("/groups/:groupId/members", groupH.ListTeamGroupMembers) teamScoped.POST("/groups/:groupId/members", groupH.AddTeamGroupMember) // RequireTeamAdmin teamScoped.DELETE("/groups/:groupId/members/:userId", groupH.RemoveTeamGroupMember) // RequireTeamAdmin // Admin: global groups admin.GET("/groups", groupH.ListAllGroups) admin.POST("/groups", groupH.CreateGlobalGroup) admin.PUT("/groups/:id", groupH.UpdateGroup) admin.DELETE("/groups/:id", groupH.DeleteGroup) admin.GET("/groups/:id/members", groupH.ListGroupMembers) admin.POST("/groups/:id/members", groupH.AddGroupMember) admin.DELETE("/groups/:id/members/:userId", groupH.RemoveGroupMember) // ── Resource Grants ───────────────────── // Persona grants (add to existing preset routes) protected.GET("/presets/:id/grants", personaH.GetGrants) protected.PUT("/presets/:id/grants", personaH.SetGrants) // KB grants (add to existing KB routes) protected.GET("/knowledge-bases/:id/grants", kbH.GetGrants) protected.PUT("/knowledge-bases/:id/grants", kbH.SetGrants) ``` --- ## 7. Audit Logging All group and grant operations log to the existing audit system: | Action | Resource Type | Details | |--------|--------------|---------| | `group.create` | `group` | name, scope, team_id | | `group.update` | `group` | changed fields | | `group.delete` | `group` | name | | `group.member.add` | `group` | user_id | | `group.member.remove` | `group` | user_id | | `resource_grant.set` | `persona` / `knowledge_base` | grant_scope, group count | | `resource_grant.delete` | `persona` / `knowledge_base` | — | Uses the existing `AuditStore.Log` method — no new infrastructure. --- ## 8. Frontend ### 8.1 Admin Panel: Groups Section New tab in the admin panel (alongside Users, Teams, Models, Settings, etc.): **Groups list view:** - Table: Name, Scope, Team (if team-scoped), Members count, Actions - "Create Group" button → modal with name, description, scope selector - Click row → member management (add/remove users, search by username) **Team admin panel:** - New "Groups" tab under team settings - Same list/CRUD pattern, scoped to team ### 8.2 Grant Picker Component Reusable component for Persona and KB create/edit forms: ``` ┌─ Access ──────────────────────────────────┐ │ ○ Team Only (default) │ │ ○ All Users │ │ ○ Specific Groups │ │ ┌────────────────────────────────────┐ │ │ │ [x] Engineering │ │ │ │ [x] Design │ │ │ │ [ ] Marketing │ │ │ │ [ ] Leadership │ │ │ └────────────────────────────────────┘ │ └────────────────────────────────────────────┘ ``` - Only shown for team-scoped and global-scoped resources (personal resources are always owner-only). - "Team Only" = existing behavior (no `resource_grants` row, or row with `grant_scope = 'team_only'`). - "All Users" = `grant_scope = 'global'`. - "Specific Groups" = `grant_scope = 'groups'` + multi-select group picker. - Group list loaded from `/api/v1/groups/mine` (user's groups) for team admins, `/api/v1/admin/groups` for system admins. ### 8.3 User's Group Badge The model selector and KB list already show scope badges (`global`, `team`, `personal`). For group-granted resources, show a "group" badge to distinguish from team-native access. --- ## 9. Backward Compatibility ### 9.1 No Breaking Changes - All existing scope-based queries remain valid. - Resources without a `resource_grants` row behave exactly as before. - The `resource_grants` table is opt-in: only populated when an admin or team admin explicitly sets grants on a resource. - `persona_grants` (tool/KB/API grants ON a persona) is unrelated to `resource_grants` (access grants TO a persona). Different concepts, different tables. No rename needed. ### 9.2 Migration Safety - New tables only (no ALTER on existing tables). - No data migration required. - Rollback: drop the three new tables. ### 9.3 Naming Clarity To avoid confusion between the existing `persona_grants` table (which controls what tools/KBs a Persona has access TO) and the new `resource_grants` table (which controls which users have access TO the Persona): | Table | Purpose | Example | |-------|---------|---------| | `persona_grants` | What a Persona can do (tools, KBs, APIs) | "CodeBot can use calculator" | | `resource_grants` | Who can use a resource (users via groups) | "Engineering group can use CodeBot" | The naming is intentionally distinct. No rename of `persona_grants` is needed. --- ## 10. Implementation Phases ### Phase 1: Groups Entity (backend) - [ ] Migration `010_groups.sql` - [ ] Models: `Group`, `GroupPatch`, `GroupMember` - [ ] Store: `GroupStore` interface + Postgres implementation - [ ] Handler: `GroupHandler` with admin + team admin + user endpoints - [ ] Routes: wire into `main.go` - [ ] Audit logging for group operations - [ ] Integration tests: CRUD, membership, scoping ### Phase 2: Resource Grants (backend) - [ ] Models: `ResourceGrant` + constants - [ ] Store: `ResourceGrantStore` interface + Postgres implementation - [ ] Grant endpoints on PersonaHandler and KBHandler - [ ] Update `PersonaStore.ListForUser` with UNION for group access - [ ] Update `PersonaStore.UserCanAccess` with group check - [ ] Update `KnowledgeBaseStore.ListForUser` with UNION for group access - [ ] Update `KBHandler.userCanAccess` with group check - [ ] Update `KnowledgeBaseStore.GetActiveKBIDs` with group path - [ ] Audit logging for grant operations - [ ] Integration tests: grant CRUD, access resolution, cross-team visibility ### Phase 3: Frontend - [ ] Admin panel: Groups section (CRUD, member management) - [ ] Team admin panel: Groups tab - [ ] Grant picker component (shared between Persona and KB forms) - [ ] `/groups/mine` endpoint for user's group list - [ ] "Group" scope badge on model selector / KB list - [ ] Update Persona create/edit form with grant picker - [ ] Update KB create/edit form with grant picker --- ## 11. Testing Plan ### Integration Tests (extend `handlers/integration_test.go`) **Group lifecycle:** 1. Admin creates global group → verify in list 2. Admin adds users to group → verify membership 3. Team admin creates team group → verify scoping (not visible to other teams) 4. Remove member → verify no longer in group 5. Delete group → verify CASCADE cleans up members **Resource grants:** 1. Team admin creates a KB with `grant_scope = 'groups'`, grants to a group 2. User in the group can see the KB in `ListForUser` → verify access 3. User NOT in the group cannot see the KB → verify denied 4. User in group can search the KB via `kb_search` tool → verify access 5. Remove user from group → verify KB no longer visible 6. Change grant from `groups` to `global` → verify all users can access 7. Delete grant → verify KB reverts to team-only visibility **Backward compatibility:** 1. Resources with no `resource_grants` row → same behavior as before 2. Existing scope queries → unchanged results 3. Personal resources → never grant-accessible (grant picker hidden) ### Edge Cases - User belongs to 0 groups → `ARRAY_AGG` returns NULL → no match (safe) - Group with 0 members → grant exists but no one matches (correct) - Delete a group that's referenced in `granted_groups` → array still contains the UUID but no `group_members` rows match → effectively revoked (correct). Cleanup: periodic job or ON DELETE trigger to scrub stale UUIDs from arrays. For v0.16.0, stale UUIDs are harmless (they just don't match anything). --- ## 12. Future Considerations ### v0.17.0 — Persona-KB Binding Resource grants enable the access control layer for Persona-KB binding. When a Persona has bound KBs, the Persona's grants implicitly grant search access to those KBs. The `resource_grants` table already supports this — the Persona grant controls who can use the Persona, and Persona-KB binding controls which KBs are searchable through it. ### v0.19.0 — Projects Projects will use the same `resource_grants` pattern: `resource_type = 'project'`. No schema changes needed. ### v0.24.0 — Full RBAC OIDC claim → group mapping will auto-sync external IdP groups into the `groups` table. The infrastructure built here becomes the foundation for enterprise RBAC. ### Stale Group UUID Cleanup When a group is deleted, its UUID may linger in `resource_grants.granted_groups` arrays. This is harmless (dead UUIDs never match any `group_members` rows) but untidy. Options for a future cleanup: - Trigger on `groups` DELETE that scrubs the UUID from all `granted_groups` arrays. - Periodic background job. - Cleanup on next grant SET (when the admin edits the grant). For v0.16.0, we'll add a note in the delete handler response if the group is referenced in any grants, warning the admin.