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
effectiveRole = member.roleOverride ?? team.baseRole
JPQL Query Patterns
Staff workspace access check:
SELECT COUNT(*) > 0
FROM Member tm
JOIN Team t ON t.id = tm.teamId
JOIN TeamWorkspaceAssignment twa ON twa.teamId = t.id
WHERE twa.workspaceId = :workspaceId
AND tm.userId = :userId
AND COALESCE(tm.roleOverride, t.baseRole) IN :roles
Org-level role check:
SELECT COUNT(*) > 0
FROM Member tm
JOIN Team t ON t.id = tm.teamId
WHERE t.orgId = :orgId
AND tm.userId = :userId
AND COALESCE(tm.roleOverride, t.baseRole) 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 |
team.baseRole + member.roleOverride with COALESCE | Single DB query for effective role — no join to a roles table, no list of per-member roles |
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