Skip to content

Party Archetype — Design Spec

Date: 2026-04-14 Status: Approved Scope: Users, groups, projects, project-scoped permissions, catalog entry project membership


1. Problem Statement

AgentLens currently has a flat, global permission model: every user has one global role (admin, editor, viewer) and all catalog entries are visible to all authenticated users. There is no concept of projects, group membership, or per-project permissions.

The goals of this feature:

  • Introduce projects as logical namespaces (à la ArgoCD AppProject) that scope catalog entries
  • Introduce groups as hierarchical collections of users that can carry project roles
  • Allow catalog entries to belong to multiple projects simultaneously
  • Enforce project-scoped RBAC in addition to existing global roles
  • Model all actors (users, groups, future org types) using the Party archetype as a unified abstraction
  • Keep the system open to new party kinds (e.g. organization) with zero handler code changes

2. Source Archetype — Java Mapping

The party archetype defines:

Java concept AgentLens Go mapping
Party (sealed abstract) Party struct + PartyKind discriminator
Person Party{Kind: "person"} linked to existing User (auth lives in User)
Organization / OrganizationUnit Party{Kind: "group"} — nested via relationships
Company Party{Kind: "project"} — logical namespace
Role (value object) plain string — no separate table
PartyRole (PartyID, RoleName) pair — embedded in relationship edges
PartyRelationship PartyRelationship struct — directed named graph edge
RegisteredIdentifier PartyIdentifier struct — external IDs (email, username)

Go does not have sealed types. The discriminator pattern (kind field + validation in store layer) replaces Java's sealed class hierarchy.


3. Core Domain Model

3.1 Party

type PartyKind string

const (
    PartyKindPerson  PartyKind = "person"
    PartyKindGroup   PartyKind = "group"
    PartyKindProject PartyKind = "project"
    // Future: PartyKindOrganization PartyKind = "organization"
)

type Party struct {
    ID        string    `gorm:"primaryKey"`
    Kind      PartyKind `gorm:"not null"`
    Name      string    `gorm:"not null"`
    Version   int       `gorm:"not null;default:0"`
    UserID    *string   `gorm:"uniqueIndex"` // non-null for persons only, FK→users.id
    IsSystem  bool      `gorm:"default:false"` // true for default project / system groups
    CreatedAt time.Time
    UpdatedAt time.Time
}
  • Person parties are created automatically when a User is created (migration + bootstrap).
  • Group parties are user-managed. Groups can contain persons or other groups (hierarchical).
  • Project parties are user-managed namespaces. One system project (is_system=true, name="default") is seeded at migration time.

3.2 PartyRelationship (graph edge)

type PartyRelationship struct {
    ID               string `gorm:"primaryKey"`
    FromPartyID      string `gorm:"not null;index"`
    FromRole         string `gorm:"not null"`
    ToPartyID        string `gorm:"not null;index"`
    ToRole           string `gorm:"not null"`
    RelationshipName string `gorm:"not null"`
    CreatedAt        time.Time
    // UNIQUE(FromPartyID, FromRole, ToPartyID, ToRole, RelationshipName)
}

Relationship taxonomy:

RelationshipName FromRole ToRole Meaning
group_member member group Person or group is a member of a group
project_member project:owner | project:developer | project:viewer project Party has a role within a project

New party kinds add a new row to this taxonomy. No schema change required.

3.3 Party Group Closure (materialized)

type PartyGroupClosure struct {
    MemberPartyID   string `gorm:"primaryKey"`
    AncestorPartyID string `gorm:"primaryKey"`
}

Pre-computed transitive closure of all containment relationships (see §3.4). Rebuilt synchronously (in the same transaction) whenever a containment relationship is added or removed. Enables O(1) indexed auth checks — no recursive CTE at request time.

3.4 Containment Relationship Registry

// ContainmentRelationships is the set of relationship names that form
// hierarchical containment and trigger closure rebuilds.
// Adding a new hierarchical party kind costs exactly one entry here.
var ContainmentRelationships = map[string]bool{
    "group_member": true,
    // "org_member": true  ← future: adding "organization" kind costs one line
}

The closure rebuild fires for any relationship whose name is in ContainmentRelationships. PartyGroupClosure stores (member_party_id, ancestor_party_id) regardless of kind — the permission resolution algorithm queries it without filtering by kind.

ContainmentRelationships lives in internal/model/party.go (not auth/) so that store can import it without violating the arch-go auth → model-only rule.

3.5 Global Party Roles (groups only)

type GlobalPartyRole struct {
    PartyID string `gorm:"primaryKey"` // kind=group (or future hierarchical kinds)
    RoleID  string `gorm:"primaryKey"` // FK→roles.id
}

Person parties derive their global role from users.role_id (unchanged). Groups can additionally carry global roles (e.g. a platform-admins group with global:admin).

3.6 Catalog Project Membership

type CatalogProjectMembership struct {
    CatalogEntryID string `gorm:"primaryKey"` // FK→catalog_entries.id
    ProjectPartyID string `gorm:"primaryKey"` // FK→parties.id, kind=project
    CreatedAt      time.Time
}

Many-to-many. Every new CatalogEntry is automatically inserted into the default project at creation time. Additional project assignments are explicit.

3.7 Party Identifier (v2)

type PartyIdentifier struct {
    ID        string `gorm:"primaryKey"`
    PartyID   string `gorm:"not null;index"`
    Kind      string `gorm:"not null"` // "email", "username", "external_id"
    Value     string `gorm:"not null"`
    CreatedAt time.Time
    // UNIQUE(Kind, Value)
}

Schema added in v1 migration, not populated until v2. Included for archetype completeness.


4. Permission Resolution

4.1 Project-scoped permission matrix

Project role Permissions granted within project
project:owner catalog:read, catalog:write, catalog:delete
project:developer catalog:read, catalog:write
project:viewer catalog:read

Static in-memory map — no DB table. New project roles = one map entry in auth/party_permissions.go.

4.2 Resolution algorithm

HasProjectPermission(ctx, userID, permission, projectPartyID) bool:

  Step 1 — Global bypass (existing system, unchanged)
    user.Role.Permissions ∋ permission → ALLOW

  Step 2 — Group global roles
    SELECT r.permissions FROM global_party_roles gpr
    JOIN roles r ON r.id = gpr.role_id
    WHERE gpr.party_id IN (
      SELECT ancestor_party_id FROM party_group_closures
      WHERE member_party_id = $userPartyID
    )
    any permissions set ∋ permission → ALLOW

  Step 3 — Project-scoped roles (user direct + via group ancestry)
    SELECT from_role FROM party_relationships
    WHERE relationship_name = 'project_member'
      AND to_party_id = $projectPartyID
      AND from_party_id IN (
            $userPartyID,
            SELECT ancestor_party_id FROM party_group_closures
            WHERE member_party_id = $userPartyID
          )
    map from_role → permission set (static in-memory table)
    any mapped permissions ∋ permission → ALLOW

  Step 4 — DENY

All lookups use indexed columns. No recursive CTE at auth time (closure is pre-materialized).

4.3 Catalog entry access

HasCatalogPermission(ctx, userID, permission, catalogEntryID) bool:
  projectIDs = SELECT project_party_id FROM catalog_project_memberships
               WHERE catalog_entry_id = $catalogEntryID
  return ANY projectID: HasProjectPermission(ctx, userID, permission, projectID)

4.4 Per-request context cache

RequireProjectPermission middleware caches the user's ancestor party IDs (from party_group_closures) in r.Context() on the first call per request. Subsequent middleware calls on the same request reuse the cached set — single DB round-trip per request for group ancestry resolution.


5. API Design

5.1 Generic handler architecture

All party kinds share a single handler implementation, parameterized by PartyKindConfig:

// PartyKindConfig drives route registration and handler behaviour for one party kind.
// Adding a new party kind = add a new PartyKindConfig. Zero handler code changes.
type PartyKindConfig struct {
    Kind               PartyKind   // "group", "project", "organization"
    URLPrefix          string      // "groups", "projects", "orgs"
    MemberRelationship string      // relationship_name for members: "group_member", "project_member"
    ValidMemberRoles   []string    // roles a member may hold in this kind
    CreatePermission   string      // global permission required to create
    ManagePermission   string      // global permission required to delete / modify
    CanContainKinds    []PartyKind // which party kinds may be added as members
}

Route registration in router.go — called inside the authenticated /api/v1 group (matching the existing registerCatalogRoutes/registerUserRoutes pattern):

// Inside r.Group(func(r chi.Router) { r.Use(RequireAuth(...)) ... }):
RegisterPartyKindRoutes(r, PartyKindConfig{   // r.Route("/groups", ...)
    Kind: PartyKindGroup, URLPrefix: "groups",
    MemberRelationship: "group_member",
    ValidMemberRoles:   []string{"member"},
    CreatePermission:   "users:write", ManagePermission: "users:write",
    CanContainKinds:    []PartyKind{PartyKindPerson, PartyKindGroup},
})
RegisterPartyKindRoutes(r, PartyKindConfig{   // r.Route("/projects", ...)
    Kind: PartyKindProject, URLPrefix: "projects",
    MemberRelationship: "project_member",
    ValidMemberRoles:   []string{"project:owner", "project:developer", "project:viewer"},
    CreatePermission:   "catalog:write", ManagePermission: "catalog:write",
    CanContainKinds:    []PartyKind{PartyKindPerson, PartyKindGroup},
})
// Future: RegisterPartyKindRoutes(r, PartyKindConfig{Kind: PartyKindOrganization, ...})

RegisterPartyKindRoutes uses relative path prefixes (/groups, not /api/v1/groups) because it is called inside the already-mounted /api/v1 subrouter.

5.2 Generic endpoints (registered per kind)

GET    /api/v1/{prefix}                         ListParties(cfg)
POST   /api/v1/{prefix}                         CreateParty(cfg)
GET    /api/v1/{prefix}/{id}                    GetParty(cfg)
DELETE /api/v1/{prefix}/{id}                    DeleteParty(cfg)   [non-system only]
POST   /api/v1/{prefix}/{id}/members            AddMember(cfg)
DELETE /api/v1/{prefix}/{id}/members/{memberID} RemoveMember(cfg)
GET    /api/v1/{prefix}/{id}/members            ListMembers(cfg)

5.3 Catalog scoping endpoints (catalog-specific, not generic)

POST   /api/v1/catalog/{id}/projects             assign entry to additional project
DELETE /api/v1/catalog/{id}/projects/{projectID} remove from project
GET    /api/v1/catalog?project={projectID}        filter catalog by project

5.4 New middleware

// RequireProjectPermission is project-aware auth middleware.
// Reads projectID from chi URL param, resolves permission via closure,
// caches ancestor parties in context for the request lifetime.
// Needs PartyStore (closure lookups) and UserStore (group global-role lookups).
func RequireProjectPermission(ps *store.PartyStore, us *store.UserStore, projectIDParam, permission string) func(http.Handler) http.Handler

Existing RequirePermission middleware (global) is unchanged and continues to guard all existing endpoints.


6. Layer Boundaries (arch-go compliant)

internal/model/
  party.go              Party, PartyKind, PartyRelationship, PartyGroupClosure,
                        GlobalPartyRole, CatalogProjectMembership, PartyIdentifier,
                        ContainmentRelationships (registry) ← lives here, NOT in auth/
                        ValidPartyKinds, ContainmentRelationshipNames()

internal/store/
  party_store.go        PartyStore — generic CRUD for parties, relationships,
                        full-table closure rebuild (same tx as relationship changes),
                        cycle detection on AddMember, cascade cleanup on DeleteParty
                        Methods: CreateParty, AddMember, RemoveMember, ListMembers,
                                 GetParty, DeleteParty, ListParties,
                                 AncestorGroupIDs, GetDefaultProject,
                                 AssignToProject, RemoveFromProject,
                                 ListProjectsForCatalogEntry,
                                 ListCatalogEntryIDsForProject, GetProjectRoles
  sql_store.go          Modified: add ProjectID to ListFilter, handle via JOIN in List;
                                  add partyStore field, auto-assign new entries to default project
  store.go              Modified: add ProjectID string to ListFilter struct

internal/auth/
  party_permissions.go  HasProjectPermission (pure, no DB),
                        projectRolePermissions static map,
                        ValidProjectRoles() (sorted)
                        Note: ContainmentRelationships is in model/, not here

internal/api/
  party_handlers.go     Generic CRUD + member handlers (parameterized by PartyKindConfig)
  party_kind_configs.go PartyKindConfig definitions for group and project
  party_middleware.go   RequireProjectPermission (4-arg: ps, us, paramName, permission)
                        + ancestor context cache (defensive copy)
  catalog_project.go    Catalog↔project scoping handlers (POST/DELETE/GET)
  router.go             Modified: add registerPartyRoutes(r, deps) called inside
                        the authenticated JWTService group (same pattern as registerCatalogRoutes)

internal/db/
  migrations.go         Migration 007: all party tables + seed data (Go-side UUID, dialect-agnostic)

No new arch-go dependency rules required. All placements fit existing layer contracts.

Key arch constraint: store imports model (to read ContainmentRelationships). auth imports model only (pure permission map). api imports store + auth. This is the existing layering — no changes needed.


7. Extensibility — Adding a New Party Kind

Adding organization (or any future kind) costs:

Change Location Cost
PartyKindOrganization constant model/party.go 1 line
"org_member" in ContainmentRelationships model/party.go 1 line
Kind validation entry in ValidPartyKinds model/party.go 1 line
PartyKindConfig{...} registration api/party_kind_configs.go ~10 lines
Migration none — parties table already has kind discriminator 0 lines

Permission resolution, closure rebuild, store methods, and all handlers — unchanged.


8. Migration Strategy

Migration 007 — next in sequence after migration006RawCards (idempotent):

1. CREATE TABLE parties (id, kind, name, version, user_id, is_system, ...)
2. CREATE TABLE party_relationships (id, from_party_id, from_role, to_party_id, to_role, relationship_name)
3. CREATE TABLE party_group_closures (member_party_id, ancestor_party_id)    ← plural (GORM default)
4. CREATE TABLE global_party_roles (party_id, role_id)
5. CREATE TABLE catalog_project_memberships (catalog_entry_id, project_party_id)
6. CREATE TABLE party_identifiers (id, party_id, kind, value)   [schema only, not populated]
7. INSERT INTO parties: default project (kind='project', is_system=true, name='default')
8. INSERT INTO parties: one Person party per existing User (kind='person', user_id=u.id)
   — Go-side loop with uuid.New().String() (dialect-agnostic; no SQLite randomblob)
9. INSERT INTO catalog_project_memberships: all existing CatalogEntries → default project

Backward compatibility:

  • All existing API endpoints work unchanged after migration
  • ?project= query param on GET /api/v1/catalog is opt-in; omitting it returns all entries the caller has global access to (existing behaviour preserved)
  • RequireProjectPermission only guards new endpoints; existing endpoints keep RequirePermission
  • Bootstrap admin creation gains a corresponding Person party automatically (created immediately after BootstrapAdmin returns — same startup flow in main.go)

Invariants maintained by the store layer:

  • AddMember detects and rejects cycles before inserting any containment relationship
  • DeleteParty cascades: removes party_relationships, party_group_closures, global_party_roles, and catalog_project_memberships rows in the same transaction, then rebuilds the full closure
  • Closure rebuild (rebuildAllClosures) is a full-table rebuild — delete all rows, re-insert via a single recursive CTE. Correct regardless of edge insertion order.

9. Out of Scope (v1)

  • Party addresses (from archetype) — not relevant to AgentLens
  • PartyIdentifier population — schema added, populated in v2
  • Invitation / approval flows for project membership
  • Project-level source/destination restrictions
  • Audit log for membership changes (enterprise plugin concern)
  • UI for project/group management (backend API only in v1)