Technical Architecture
Developer reference. Covers the full entity model, permission evaluation, invitation flows, and system component boundaries as implemented after the IAM & Membership Refactor (March 2026).
System Components
Division of responsibilities:
| System | Owns |
|---|---|
| Keycloak | User accounts, org membership, account setup emails, OIDC tokens |
| AMS (Spring Boot) | Teams, memberships, workspace assignments, roles, permission evaluation, applications, documents, invitation activation (via scheduler polling) |
| PostgreSQL | All AMS-managed data — authoritative source for roles and access |
Full Entity Relationship Diagram
Reflects the post-refactor entity model (IAM & Membership Refactor, March 2026).
Permission Evaluation
All permission checks are implemented in AxveroRolePermissionEvaluator and resolved via DB queries — no Keycloak group reads.
Effective Role Computation
Member.role is a direct MemberRole field — there is no baseRole on Team and no roleOverride on Member. The role stored on the Member record is the effective role.
JPQL Query Patterns
Staff workspace access check (actual JPQL from MemberRepository):
SELECT COUNT(m) > 0
FROM Member m
WHERE m.user.id = :userId
AND m.team.id IN (
SELECT twa.teamId FROM TeamWorkspaceAssignment twa
WHERE twa.workspaceId = :workspaceId
)
AND m.role IN :roles
Org-level role check (via OrgMemberRepository — uses OrgRole, not MemberRole):
SELECT COUNT(om) > 0
FROM OrgMember om
WHERE om.orgId = :orgId
AND om.userId = :userId
AND om.role IN :roles
Workspace discovery for bootstrap:
-- Staff: accessible via team membership
SELECT DISTINCT w FROM OrgWorkspace w
WHERE w.id IN (
SELECT twa.workspaceId FROM TeamWorkspaceAssignment twa
WHERE twa.teamId IN (
SELECT m.teamId FROM Member m WHERE m.userId = :userId
)
)
-- Clients: assigned to org, workspace resolved via org FK
SELECT DISTINCT w FROM OrgWorkspace w
JOIN Client c ON c.orgId = w.orgId
WHERE c.userId = :userId
Staff Invitation Sequence
On completion (detected by KeycloakSyncScheduler.syncPendingInvitations, runs every 2 minutes):
Why
executeActionsEmailinstead ofinviteExistingUser:inviteExistingUsergenerates an org-join link. Since we calladdOrganizationMemberfirst (to make the user immediately usable), clicking the join link fails with "already a member."executeActionsEmailsends a pure account-setup link (verify email, set password, update profile) with no org-join action — this works correctly regardless of org membership state.
Async Job System
Long-running operations (demo org seeding, future: report generation, bulk exports) use a DB-backed async job pattern. The mutation returns immediately; the frontend polls for progress.
Mutation → asyncJobService.createJob(type) → PENDING row
→ @Async method fires (different Spring bean — AOP proxy requirement)
→ markRunning / updateProgress / complete | fail
Frontend → polls jobProgress(jobId) every 1 s via TanStack Query refetchInterval
→ stops polling when status = COMPLETED | FAILED
Key rules:
AsyncJobmust NOT extendBaseEntity(no slug/entityType required)currentStepstores an i18n key — frontend resolves viat(currentStep)resultPayloadis a TEXT column containing a JSON string@Asyncmethod must live in a separate Spring bean from its caller (self-invocation bypasses AOP proxy)ObjectMapper MAPPER = new ObjectMapper()— static final, not a Spring bean
See docu/docs/ai/claude/rules/async-jobs.md for the full wiring guide and pitfalls list.
Scheduler Overview
KeycloakSyncScheduler runs two recurring jobs:
| Job | Schedule | Purpose |
|---|---|---|
syncPendingInvitations | Every 2 minutes | Polls Keycloak for INVITED members/clients whose requiredActions list is empty → sets status = ACTIVE on the Member / Client record |
syncAllUsers | Every 100 minutes | Ensures all Keycloak realm users have an AMS User record (onboarding sync) |
OrphanInvitationCleanupScheduler runs daily at 03:00 to delete abandoned invitation accounts.
Orphan Cleanup Scheduler
Runs daily at 03:00 (OrphanInvitationCleanupScheduler).
Package Structure
com.axvero.ams.core
├── user/ User, UserService, UserController
├── organization/ Organization, OrganizationService, OrgPublicProfile
├── workspace/ Workspace (base), OrgWorkspace, UserWorkspace
│ └── domain/ WorkspaceRepository, WorkspacePurpose
├── team/ Team, TeamService, TeamController
│ └── domain/ TeamRepository, TeamWorkspaceAssignment, TeamType
├── member/ Member (= TeamMember), MemberService
├── client/ Client, ClientService, ClientController
├── application/ Application, ApplicationService
├── document/ DocumentDefinition, ApplicationTemplate, ApplicationStatus
├── job/ AsyncJob entity, AsyncJobService, AsyncJobController
│ └── domain/ AsyncJobRepository, AsyncJobStatus, AsyncJobType
├── keycloak/ KeycloakService, KeycloakSyncScheduler, KeycloakEventHandler
│ └── OrphanInvitationCleanupScheduler
├── config/
│ └── security/ AxveroRolePermissionEvaluator, SecurityConfig
└── slug/ SlugService
GraphQL Schema Layout
src/main/resources/graphql/
├── queries.graphqls Root Query type
├── mutations.graphqls Root Mutation type
├── common.graphqls Shared scalars + pagination types
├── users.graphqls User, UserProfile types
├── organizations.graphqls Organization, OrgRelationship, OrgPublicProfile types
├── workspaces.graphqls Workspace, OrgWorkspace, WorkspacePurpose types
├── teams.graphqls Team, TeamWorkspaceAssignment types
├── members.graphqls Member (TeamMember) types
├── clients.graphqls Client types
├── application.graphqls Application types
├── documents.graphqls DocumentDefinition, ApplicationTemplate, ApplicationStatus
├── workflow.graphqls WorkflowStep, WorkflowEngine types
├── job.graphqls AsyncJob, AsyncJobStatus, AsyncJobType, jobProgress query
├── bootstrap.graphqls BootstrapInfo type
└── admin.graphqls (planned) Admin-only mutations
Key Design Decisions
| Decision | Technical Rationale |
|---|---|
Team IDs generated by AMS (UUID.randomUUID()) | Decouples entity creation from Keycloak availability — team creation never fails if Keycloak is down |
Member.role as a direct MemberRole field | Single DB query for effective role — no join to a roles table, no baseRole/roleOverride indirection |
TeamWorkspaceAssignment join table | Allows many-to-many team↔workspace without modifying either entity; workspace discovery is a simple IN subquery |
| Keycloak group role hierarchy removed | All permission evaluation is in AMS DB — no Keycloak group reads at runtime. Simpler, faster, no Keycloak dependency on hot path |
Client.orgId (not workspaceId) | Clients belong to an org as a whole, not to a specific workspace. Org-scoping avoids tying a client to a single workspace and allows them to participate in applications across all org workspaces |
executeActionsEmail instead of inviteExistingUser | inviteExistingUser creates an org-join link. Since the user is already added to the org via addOrganizationMember, clicking that link fails with "already a member." executeActionsEmail sends a pure account-setup link (verify + password + profile) with no org-join action. |
| Invitation activation via scheduler polling | Keycloak does not yet post events to AMS. The scheduler polls UserRepresentation.getRequiredActions() every 2 minutes — empty list means the user completed onboarding. |
| Accounts pre-created on invitation | Office can build applications and assign people immediately. Orphan cleanup handles the abandoned-invite case. |
OrgPublicProfile invariant | Always created with OrgWorkspace — there must never be an org workspace without a public profile |
Invitation fields merged into Client / Member | The Keycloak account is created at invitation time, so the person IS already a Client / Member — just not yet active. Separate TeamInvitation / ClientInvitation entities (and the BaseInvitation superclass) added complexity without benefit. A single status field (INVITED → ACTIVE) on the existing entity is sufficient. sentDate, expiresAt, reminderSent are nullable columns used only while status = INVITED. Cancel = delete the record + delete the Keycloak account; Resend = call executeActionsEmail again. |
Frontend Architecture
Route Tree
src/routes/_authenticated/
├── p/$workspaceSlug/ Personal workspace
│ ├── applications/ Cross-org aggregated applications
│ └── settings/
└── o/$orgSlug/ Organization routes
├── teams/ Team management (org-scoped)
│ └── $teamSlug/ Team detail + members
├── clients/ Client management
├── partners/ Org relationships
├── settings/ Org settings
└── w/$workspaceSlug/ Workspace work context
├── applications/ Applications in this workspace
│ └── $id/ Application detail
├── messages/ Workspace messages
└── setup/ Templates, docs, workflow