Aller au contenu

Platform Vision — Shared Infrastructure Architecture

Scope: Cross-cutting concerns reused by ALL modules
Product: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite
Version: 1.0
Date: 2026-03-13
Author: ARCHITECT (Claude Code)
Input documents:
- docs/prd/platform-vision-prd.md (Platform Vision PRD v1.0) - docs/architecture/platform-saas-blueprint.md (SaaS Blueprint v2) - docs/architecture/control-plane-arch.md (Control Plane Architecture v1.0) - docs/standards/*.md (all coding standards)


Table of Contents

  1. Purpose & Scope
  2. System Context
  3. TenantContext Propagation
  4. Dual DataSource & Repository Patterns
  5. Cross-Module Event Architecture
  6. API Response Envelope & Error Handling
  7. Module Gating
  8. Audit Trail Integration
  9. Offline & Sync Framework
  10. i18n Architecture
  11. API Versioning Strategy
  12. Security Architecture
  13. Multi-Tenant Strategy (All 4 DB Profiles)
  14. Flyway Migration Strategy
  15. Performance & Caching
  16. Shared Test Infrastructure
  17. Local Dev Environment
  18. Frontend Architecture
  19. Vertical Slice Decomposition
  20. Architectural Decision Records
  21. Technical Risks & Mitigations

1. Purpose & Scope

This document defines the shared infrastructure patterns that every module on the ALTARYS platform must use. It is NOT a per-module architecture — it is the foundation layer.

What this document covers: - TenantContext propagation (the reusable pattern, not CP-specific implementation) - Dual DataSource wiring and repository patterns for all modules - Cross-module event contracts and shared event module - API response envelope and global error handling - Module gating (activation checks) - Audit trail integration pattern - Offline sync framework for frontends - i18n architecture - API versioning strategy - Shared test infrastructure - Local dev environment (docker-compose)

What already exists (DO NOT duplicate — reference only): - docs/architecture/control-plane-arch.md — Full CP architecture (Tenant, Identity, Billing, Organization, Audit, Notification modules) - docs/architecture/platform-saas-blueprint.md — AWS SaaS Factory adaptation, 4 DB profiles, hosting strategy

Relationship to existing arch docs: Every per-module arch doc (control-plane, budget, absence, future modules) depends on the patterns defined here. When a module-specific arch doc and this document disagree, this document wins — it represents the latest cross-cutting decisions.


2. System Context

2.1 Two-Plane Architecture

graph TB
    subgraph "Control Plane (com.altarys.papillon.platform.*)"
        TENANT[Tenant Module]
        IDENTITY[Identity Module]
        BILLING[Billing Module]
        ORG[Organization Module]
        AUDIT[Audit Module]
        NOTIF[Notification Module]
    end

    subgraph "Application Plane (com.altarys.papillon.modules.*)"
        ABS[Absence<br/>ABSMGT]
        BUD[Budget Lines<br/>BUDMGT]
        ATT[QR Attendance<br/>ATTMGT]
        PAY[Payroll<br/>PAYMGT]
        CMT[Commitment<br/>COMMIT]
        EXP[Expense Mgmt<br/>EXPMGT]
        COREHR[Core HR<br/>COREMGT]
        PERF[Performance<br/>PERFMGT]
        GPEC[Skills & GPEC<br/>GPECMGT]
        ATS[Recruitment<br/>ATSMGT]
        LEARN[Learning<br/>LRNMGT]
        HSE[HSE<br/>HSEMGT]
    end

    subgraph "Shared Infrastructure"
        EVENTS[Shared Events<br/>com.altarys.papillon.platform.events]
        TESTSUP[Test Support<br/>com.altarys.papillon.testsupport]
    end

    subgraph "External Services"
        KC[Keycloak]
        PG[(PostgreSQL)]
        REDIS[(Redis)]
        MINIO[MinIO]
        EMAIL[Email Provider]
    end

    subgraph "Frontends"
        PAP["Papillon HR Suite<br/>(frontend/)"]
        ENT["Enterprise HR Suite<br/>(frontend-enterprise/)"]
        SHARED["Shared Components<br/>(packages/shared-components/)"]
    end

    ABS & BUD & ATT & PAY --> TENANT
    ABS & BUD & ATT & PAY --> ORG
    ABS & BUD & ATT & PAY --> AUDIT
    ABS & BUD & ATT & PAY --> NOTIF

    IDENTITY --> KC
    TENANT --> PG
    TENANT --> REDIS
    NOTIF --> EMAIL

    PAP --> SHARED
    ENT --> SHARED

2.2 Package Structure

backend/src/main/java/com/altarys/papillon/
├── platform/                          # Control Plane
│   ├── config/                        # Global: ApiResponse, GlobalExceptionHandler
│   ├── tenant/                        # Tenant module
│   │   ├── config/                    # TenantContext, DataSourceConfig
│   │   ├── domain/                    # Tenant, TenantStatus, TenantStateMachine
│   │   ├── repository/               # TenantSettingsRepository
│   │   ├── service/                   # TenantService (JdbcClient)
│   │   ├── api/                       # Controllers + DTOs
│   │   └── event/                     # Domain events
│   ├── identity/                      # Identity module (auth, JWT)
│   ├── billing/                       # Billing module
│   ├── organization/                  # Organization module (employees, org units)
│   ├── audit/                         # Audit module
│   └── notification/                  # Notification module
├── modules/                           # Application Plane
│   ├── absence/                       # ABSMGT
│   ├── budget/                        # BUDMGT
│   ├── attendance/                    # ATTMGT
│   └── ...                            # Future modules
└── events/                            # Shared event contracts (ADR-PV-02)
    ├── employee/                      # EmployeeCreated, etc.
    ├── absence/                       # LeaveApproved, etc.
    ├── budget/                        # BudgetThresholdReached, etc.
    ├── billing/                       # PaymentConfirmed, etc.
    └── tenant/                        # TenantProvisioned, etc.

3. TenantContext Propagation

ADR-PV-01: ScopedValue (Java 25) as primary + ThreadLocal fallback

3.1 TenantContext Record

// com.altarys.papillon.platform.tenant.config.TenantContext
public record TenantContext(
    UUID tenantId,
    String countryCode,
    String dbProfile     // "SHARED" | "SCHEMA" | "DATABASE" | "BYO"
) {}

3.2 TenantContextHolder

// com.altarys.papillon.platform.TenantContextHolder
public final class TenantContextHolder {

    public static final ScopedValue<TenantContext> TENANT_CONTEXT = ScopedValue.newInstance();
    private static final ThreadLocal<TenantContext> THREAD_LOCAL_FALLBACK = new ThreadLocal<>();

    public static TenantContext current() {
        if (TENANT_CONTEXT.isBound()) {
            return TENANT_CONTEXT.get();
        }
        TenantContext ctx = THREAD_LOCAL_FALLBACK.get();
        if (ctx == null) {
            throw new IllegalStateException("No TenantContext available");
        }
        return ctx;
    }

    // For @Scheduled tasks, Flyway migration contexts, test fixtures
    public static void setFallback(TenantContext ctx) {
        THREAD_LOCAL_FALLBACK.set(ctx);
    }

    public static void clearFallback() {
        THREAD_LOCAL_FALLBACK.remove();
    }
}

3.3 Where TenantContext is Set

Context Mechanism Set By
HTTP request ScopedValue.where(TENANT_CONTEXT, ctx).run(...) TenantResolutionFilter
@Scheduled tasks ThreadLocal fallback Scheduler wrapping code
Flyway migrations (Schema/DB/BYO profiles) ThreadLocal fallback Provisioning pipeline
Tests ThreadLocal fallback AbstractTenantIntegrationTest.withTenant()
@Async methods ThreadLocal fallback (ScopedValue doesn't propagate across thread boundaries) Async task decorator

3.4 Request Flow

sequenceDiagram
    participant Client
    participant Filter as TenantResolutionFilter
    participant SV as ScopedValue
    participant Controller
    participant Service
    participant Repository

    Client->>Filter: Request + JWT
    Filter->>Filter: Extract tenant_id from JWT claims
    Filter->>Filter: Lookup db_profile from platform DB
    Filter->>SV: ScopedValue.where(TENANT_CONTEXT, ctx)
    SV->>Controller: .run(() -> filterChain.doFilter())
    Controller->>Service: business logic
    Service->>Repository: @Query with :tenantId
    Repository->>Repository: TenantContextHolder.current().tenantId()
    Note over Filter,Repository: ScopedValue immutable for entire request scope

3.5 Rules for ALL Modules

  1. Never create your own TenantContext mechanism — always use TenantContextHolder.current()
  2. Never pass tenantId as a method parameter through service layers — read it from TenantContextHolder
  3. Always include tenant_id in every SQL query — convention enforced by CI test (ADR-PV-03)
  4. ScopedValue is immutable — you cannot change the tenant mid-request (this is a feature, not a bug)

4. Dual DataSource & Repository Patterns

ADR-PV-03: Convention + RLS + CI test (three layers of tenant isolation)

4.1 Two Databases, One Application

graph LR
    subgraph "Spring Boot Application"
        PS[Platform Services<br/>JdbcClient]
        TS[Tenant Services<br/>Spring Data JDBC]
    end

    subgraph "Platform DB"
        PT[tenant]
        PU[user_tenant_membership]
        PB[pricing_segment]
        PC[country_tax_bracket]
        PM[tenant_module]
    end

    subgraph "Tenant DB"
        TE[employee]
        TO[org_unit]
        TL[leave_request]
        TB[budget_line]
        TA[audit_entry]
    end

    PS -->|"@Qualifier('platformJdbcClient')"| PT
    PS --> PU
    PS --> PB
    TS -->|"@Primary DataSource"| TE
    TS --> TO
    TS --> TL
    TS --> TB
    TS --> TA

4.2 DataSource Configuration

// com.altarys.papillon.platform.tenant.config.DataSourceConfig
@Configuration
public class DataSourceConfig {

    // Platform DB — tenants, users, billing, country configs
    @Bean
    @Qualifier("platformDataSource")
    DataSource platformDataSource(DataSourceProperties props) { ... }

    @Bean
    @Qualifier("platformJdbcClient")
    JdbcClient platformJdbcClient(@Qualifier("platformDataSource") DataSource ds) {
        return JdbcClient.create(ds);
    }

    // Tenant DB — employees, org units, module data [PRIMARY]
    @Bean
    @Primary
    @Qualifier("tenantDataSource")
    DataSource tenantDataSource(TenantDataSourceProperties props) { ... }
}

4.3 Decision Table: Which Data Access Pattern to Use

Data Location Pattern Example
Platform DB @Qualifier("platformJdbcClient") JdbcClient TenantService, ModuleGatingService
Tenant DB (CRUD) Spring Data JDBC CrudRepository LeaveRequestRepository, BudgetLineRepository
Tenant DB (custom queries) @Query annotation on repository interface findByTenantIdAndStatus(...)
Cross-DB read Two separate calls (never a join) Service reads platform config, then tenant data

4.4 Critical Rules

  1. NEVER use CrudRepository for platform tables — Spring Data JDBC routes all CrudRepository to @Primary (tenant DB). Platform tables live on a different datasource. Use @Qualifier("platformJdbcClient") JdbcClient instead.
  2. NEVER use Instant in SpEL @Query expressions — Spring Data JDBC SpEL serializes Instant to ISO 8601 string that PostgreSQL JDBC can't parse. Use OffsetDateTime in @Query, convert in a default method.
  3. Every @Query MUST include WHERE tenant_id = :tenantId — enforced by CI reflective scan.
  4. No joins across datasources — if you need data from both, make two calls in the service layer.

4.5 Tenant ID Enforcement (Three Layers)

Layer 1: Convention
  Every @Query annotation includes "WHERE tenant_id = :tenantId"
  Developers review this in PRs

Layer 2: CI Test (automated)
  A reflective test scans all @Query annotations across all modules
  Fails if any query on a tenant DB table lacks tenant_id filter
  Runs on every PR

Layer 3: PostgreSQL RLS (defense-in-depth, Profile A only)
  ALTER TABLE leave_request ENABLE ROW LEVEL SECURITY;
  CREATE POLICY tenant_isolation ON leave_request
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
  Set via: SET LOCAL app.current_tenant_id = '<tenant-uuid>';
  Applied in TenantResolutionFilter for Profile A (shared DB)

5. Cross-Module Event Architecture

ADR-PV-02: Shared event module (com.altarys.papillon.events) ADR-14: Event-driven read models as default; Java interfaces only for atomic sync

5.1 Shared Event Catalog

All cross-module event records live in a dedicated package that every module can depend on:

com.altarys.papillon.events/
├── employee/
│   ├── EmployeeCreated.java
│   ├── EmployeeUpdated.java
│   ├── EmployeeStatusChanged.java
│   ├── ManagerChanged.java
│   └── EmployeeReassigned.java
├── absence/
│   ├── LeaveApproved.java
│   ├── LeaveCancelled.java
│   ├── LeaveBalanceSettlement.java
│   └── LeaveRequestSubmittedNotification.java
├── budget/
│   ├── BudgetThresholdReached.java
│   ├── BudgetExhausted.java
│   ├── BudgetOverrideApproved.java
│   └── BudgetOverrideRejected.java
├── billing/
│   └── PaymentConfirmed.java
└── tenant/
    ├── TenantProvisioned.java
    ├── TenantStatusChanged.java
    ├── ModuleActivated.java
    └── ModuleDeactivated.java

5.2 Event Contract Rules

  1. Events are Java records — immutable, serializable, no behavior
  2. Events carry IDs, not entitiesemployeeId, not Employee object. Consumer looks up if needed.
  3. Events are published via ApplicationEventPublisher — Spring Modulith handles persistence via EVENT_PUBLICATION table
  4. Consumers use @ApplicationModuleListener — guarantees at-least-once delivery with automatic retry
  5. Naming: <Entity><Past-tense-verb>Event (e.g., LeaveApproved, TenantProvisioned)

5.3 Event-Driven Read Models (Default Pattern)

When Module A needs data owned by Module B for read-only purposes:

sequenceDiagram
    participant OrgModule as Organization Module
    participant EventBus as Spring Application Events
    participant AbsModule as Absence Module
    participant Cache as CachedEmployeeData (Tenant DB)

    OrgModule->>EventBus: publish(EmployeeCreated)
    EventBus->>AbsModule: @ApplicationModuleListener
    AbsModule->>Cache: INSERT cached_employee_data
    Note over AbsModule: Future reads use local cache
    AbsModule->>Cache: SELECT from cached_employee_data
    Note over Cache: Eventual consistency (ms-level staleness)

When to use Java interfaces instead: ONLY for synchronous request-reply operations requiring atomic cross-module transactions. Currently, only BudgetMutationApi (BUDMGT) qualifies — for reserve/consume with immediate locking guarantee. New modules MUST NOT create Java interfaces for read-only data access.

5.4 Guaranteed Delivery

Spring Modulith's EVENT_PUBLICATION table (Flyway migration V002__create_event_publication.sql in tenant-migration) persists events before dispatch. If a listener fails:

  1. Event stays in EVENT_PUBLICATION with completion_date = NULL
  2. Spring Modulith retries automatically (configurable interval)
  3. After max retries, event is flagged for manual intervention
  4. Zero events lost — even across application restarts

6. API Response Envelope & Error Handling

ADR-PV-04: Global handler + module error codes

6.1 Standard Response Envelope

Every API response — success or error — uses the same envelope:

// com.altarys.papillon.platform.ApiResponse
public record ApiResponse<T>(
    @Nullable T data,
    @Nullable Object meta,    // Pagination cursors, counts, etc.
    @Nullable List<ApiError> errors
) {
    public static <T> ApiResponse<T> ok(T data) { ... }
    public static <T> ApiResponse<T> ok(T data, Object meta) { ... }
    public static <T> ApiResponse<T> error(List<ApiError> errors) { ... }
}

// com.altarys.papillon.platform.config.ApiError
public record ApiError(
    String code,       // Module-prefixed: "ABSENCE_001"
    String message,    // Machine-readable description (NOT user-facing)
    @Nullable String field  // For validation errors
) {}

6.2 Success Response Example

{
  "data": {
    "id": "01905b6c-7e5d-7000-8000-000000000001",
    "employeeId": "01905b6c-7e5d-7000-8000-000000000002",
    "status": "APPROVED"
  },
  "meta": null,
  "errors": null
}

6.3 Paginated Response Example (Cursor-Based)

{
  "data": [ ... ],
  "meta": {
    "cursor": "eyJpZCI6IjAxOTA1YjZjLTdlNWQifQ==",
    "hasNext": true,
    "totalCount": 142
  },
  "errors": null
}

6.4 Error Response Example

{
  "data": null,
  "meta": null,
  "errors": [
    {
      "code": "ABSENCE_001_OVERLAPPING_LEAVE",
      "message": "Leave request overlaps with existing approved leave",
      "field": null
    }
  ]
}

6.5 Global Exception Handler

One @RestControllerAdvice (GlobalExceptionHandler) handles ALL exceptions platform-wide:

Exception HTTP Status Error Code Pattern
MethodArgumentNotValidException 400 VALIDATION_ERROR (per-field)
IllegalArgumentException 400 BAD_REQUEST
AuthenticationException 401 UNAUTHORIZED
AccessDeniedException 403 FORBIDDEN
TenantNotFoundException 403 TENANT_NOT_FOUND (403 not 404 — no info leak)
ModuleNotActivatedException 403 MODULE_NOT_ACTIVE
*NotFoundException 404 <MODULE>_<ENTITY>_NOT_FOUND
OptimisticLockingFailureException 409 CONFLICT_VERSION_MISMATCH
IllegalStateTransitionException 422 <MODULE>_INVALID_STATE_TRANSITION
BusinessRuleViolationException 422 Module-specific code
Exception (catch-all) 500 INTERNAL_ERROR

6.6 Module Error Code Convention

Each module defines error codes with a consistent prefix:

ABSENCE_001_OVERLAPPING_LEAVE
ABSENCE_002_INSUFFICIENT_BALANCE
BUDGET_001_LINE_EXHAUSTED
BUDGET_002_TRANSFER_SAME_LINE
CP_001_TENANT_NOT_FOUND
CP_002_INVALID_STATE_TRANSITION

Pattern: <MODULE>_<NNN>_<DESCRIPTION> where NNN is sequential within the module.

Key rule: The message field in ApiError is machine-readable (English, for logs). The frontend maps code → user-facing French translation via i18n (see Section 10).


7. Module Gating

ADR-PV-08: Spring ModuleGatingInterceptor (URL prefix → module code mapping)

7.1 How It Works

A single HandlerInterceptor registered globally checks whether the current tenant has activated the module before allowing any request through:

// com.altarys.papillon.platform.config.ModuleGatingInterceptor
@Component
public class ModuleGatingInterceptor implements HandlerInterceptor {

    private static final Map<String, String> MODULE_MAP = Map.of(
        "/absence",    "ABSMGT",
        "/budget",     "BUDMGT",
        "/attendance", "ATTMGT",
        "/payroll",    "PAYMGT",
        "/commitment", "COMMIT",
        "/expense",    "EXPMGT"
        // CP endpoints (/api/v1/cp/*) are NOT gated — always available
    );

    private final ModuleGatingService moduleGatingService;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        String path = request.getRequestURI();
        // Extract module prefix from path: /api/v1/absence/... → /absence
        String prefix = extractModulePrefix(path);

        String moduleCode = MODULE_MAP.get(prefix);
        if (moduleCode == null) return true; // CP or unknown → pass through

        UUID tenantId = TenantContextHolder.current().tenantId();
        if (!moduleGatingService.isModuleActive(tenantId, moduleCode)) {
            throw new ModuleNotActivatedException(moduleCode);
        }
        return true;
    }
}

7.2 ModuleGatingService

// com.altarys.papillon.platform.tenant.service.ModuleGatingService
@Service
public class ModuleGatingService {

    @Cacheable(value = "module-activation", key = "#tenantId + ':' + #moduleCode")
    public boolean isModuleActive(UUID tenantId, String moduleCode) {
        // Query platform DB: SELECT status FROM tenant_module
        // WHERE tenant_id = :tenantId AND module_code = :moduleCode
        // Returns true if status = 'ACTIVE'
    }
}

Cache: Redis with 5-minute TTL. Invalidated by ModuleActivated / ModuleDeactivated.

7.3 Frontend Integration

The frontend calls GET /api/v1/cp/tenant on login, which returns the tenant's active modules. The router only renders menu items and routes for active modules.

// frontend/src/lib/moduleGating.ts
export function isModuleActive(modules: TenantModule[], code: string): boolean {
  return modules.some(m => m.code === code && m.status === 'ACTIVE');
}

8. Audit Trail Integration

ADR-PV-06: Application Events (audit module as event consumer)

8.1 Pattern

Modules do NOT call an audit service. They publish domain events (which they already do per ADR-14). The Audit module listens to those events and creates AuditEntry records.

sequenceDiagram
    participant Module as Any Module (e.g., Absence)
    participant EventBus as Spring Application Events
    participant AuditMod as Audit Module
    participant AuditDB as audit_entry (Tenant DB)

    Module->>EventBus: publish(LeaveApproved)
    EventBus->>AuditMod: @ApplicationModuleListener
    AuditMod->>AuditMod: Extract entity type, entity ID, action, payload
    AuditMod->>AuditDB: INSERT INTO audit_entry
    Note over AuditDB: Time-partitioned table (ADR-10)

8.2 AuditEntry Schema

CREATE TABLE audit_entry (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    entity_type     VARCHAR(100) NOT NULL,  -- "LeaveRequest", "BudgetLine"
    entity_id       UUID NOT NULL,
    action          VARCHAR(50) NOT NULL,   -- "CREATED", "APPROVED", "DELETED"
    user_id         UUID,                   -- NULL for system actions (cron)
    timestamp       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    payload         JSONB NOT NULL          -- Serialized event
) PARTITION BY RANGE (timestamp);

8.3 Audit Listener Registration

The Audit module registers one @ApplicationModuleListener per event type it cares about. A base handler method reduces boilerplate:

// com.altarys.papillon.platform.audit.listener.AuditEventListener
@Component
public class AuditEventListener {

    @ApplicationModuleListener
    void on(LeaveApproved e) {
        record(e.tenantId(), "LeaveRequest", e.requestId(), "APPROVED", e);
    }

    @ApplicationModuleListener
    void on(BudgetTransferApproved e) {
        record(e.tenantId(), "BudgetTransfer", e.transferId(), "APPROVED", e);
    }

    // ... one method per auditable event

    private void record(UUID tenantId, String entityType, UUID entityId,
                        String action, Object event) {
        // Serialize event to JSON, INSERT into audit_entry
    }
}

8.4 Coverage Guarantee

Since ADR-14 requires all mutations to publish events, audit coverage = event coverage. A CI test can verify: "every module that publishes at least one event has a corresponding @ApplicationModuleListener in the audit module."


9. Offline & Sync Framework

ADR-PV-05: Shared useOfflineQueue() hook in packages/shared-components/

9.1 Architecture Overview

graph TB
    subgraph "React Application"
        FORM[Form Component]
        HOOK[useOfflineQueue Hook]
        STATUS[Sync Status Provider]
    end

    subgraph "Browser Storage"
        IDB[(IndexedDB<br/>offline-queue)]
    end

    subgraph "Service Worker"
        SW[Workbox SW<br/>Background Sync<br/>safety net]
    end

    subgraph "Backend API"
        API[REST Endpoint]
    end

    FORM -->|enqueue| HOOK
    HOOK -->|persist| IDB
    HOOK -->|process FIFO| API
    HOOK -->|update| STATUS
    SW -.->|fallback replay<br/>if tab closed| API
    API -->|409 Conflict| HOOK
    HOOK -->|show conflict UI| FORM

9.2 useOfflineQueue() Hook API

// packages/shared-components/src/hooks/useOfflineQueue.ts

interface QueueItem {
  id: string;            // UUID
  method: 'POST' | 'PUT' | 'DELETE';
  url: string;
  body: unknown;
  timestamp: number;
  retryCount: number;
  status: 'pending' | 'syncing' | 'failed' | 'conflict';
}

interface UseOfflineQueueOptions {
  endpoint: string;
  conflictResolver?: (local: unknown, server: unknown) => Promise<unknown>;
  maxRetries?: number;   // Default: 3
}

interface UseOfflineQueueReturn {
  enqueue: (item: Omit<QueueItem, 'id' | 'timestamp' | 'retryCount' | 'status'>) => Promise<void>;
  syncStatus: 'synced' | 'pending' | 'syncing' | 'failed';
  pendingCount: number;
  retryAll: () => Promise<void>;
  discardFailed: () => Promise<void>;
}

export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueueReturn;

9.3 Queue Processing Rules

  1. FIFO order — items process in submission order
  2. Persist to IndexedDB — survives app restart, tab close, device reboot
  3. Auto-process when onlinenavigator.onLine + online event listener
  4. Retry with exponential backoff — 1s, 2s, 4s (max 3 retries)
  5. 409 Conflict — pause queue, show conflict resolution UI, resume after resolution
  6. After max retries — surface to user with "Réessayer / Abandonner" options
  7. Idempotency — each queued item carries a client-generated UUID. Server uses it for deduplication.

9.4 Conflict Resolution UI

When the server returns 409 (version mismatch from optimistic locking):

// Default conflict resolver — shows side-by-side comparison
function defaultConflictResolver(local: unknown, server: unknown): Promise<unknown> {
  // Opens ConflictDialog component:
  // "Garder ma version" → return local
  // "Garder la version serveur" → return server
  // "Fusionner" → open merge UI (module-specific)
}

9.5 Sync Status Indicator

Global header component driven by useOfflineQueue:

Status Visual French Label
synced 🟢 Green dot "Synchronisé"
pending 🟠 Orange dot "En attente (3)"
syncing 🟠 Pulsing orange "Synchronisation..."
failed 🔴 Red dot "Échec de synchronisation"

9.6 Service Worker (Safety Net)

Workbox Background Sync registered as a fallback. If the tab closes while items are queued, the SW replays them when connectivity returns. This is a safety net — the primary mechanism is the useOfflineQueue hook.

// frontend/src/sw.ts
import { BackgroundSyncPlugin } from 'workbox-background-sync';

const bgSyncPlugin = new BackgroundSyncPlugin('mutation-queue', {
  maxRetentionTime: 24 * 60, // 24 hours
});

10. i18n Architecture

ADR-PV-07: Namespace-per-module JSON (frontend-only translations, backend sends error codes)

10.1 Translation File Structure

frontend/src/locales/
├── fr/
│   ├── common.json          # Shared: buttons, nav, dates, status labels
│   ├── shell.json           # App shell: sidebar, header, settings
│   ├── controlPlane.json    # CP module strings
│   ├── absence.json         # Absence module strings
│   ├── budget.json          # Budget module strings
│   ├── errors.json          # Error code → user message mapping (ALL modules)
│   └── ...                  # One per module
└── en/                      # Future: English translations (same structure)
    ├── common.json
    └── ...

10.2 Key Naming Convention

// fr/absence.json
{
  "leaveRequest": {
    "title": "Nouvelle demande de congé",
    "form": {
      "startDate": "Date de début",
      "endDate": "Date de fin",
      "leaveType": "Type de congé",
      "submit": "Soumettre la demande"
    },
    "status": {
      "PENDING": "En attente",
      "APPROVED": "Approuvé",
      "REJECTED": "Refusé"
    }
  },
  "balance": {
    "title": "Solde de congés",
    "remaining": "{{count}} jour restant",
    "remaining_plural": "{{count}} jours restants"
  }
}

Pattern: <feature>.<section>.<key> — dot-separated, camelCase, max 3 levels deep.

10.3 Error Code Mapping

// fr/errors.json
{
  "ABSENCE_001_OVERLAPPING_LEAVE": "Les dates de votre demande sont en conflit avec un congé existant",
  "ABSENCE_002_INSUFFICIENT_BALANCE": "Solde de congés insuffisant ({{available}} jours disponibles)",
  "BUDGET_001_LINE_EXHAUSTED": "La ligne budgétaire « {{lineName}} » est épuisée",
  "VALIDATION_ERROR": "Veuillez corriger les erreurs du formulaire",
  "CONFLICT_VERSION_MISMATCH": "Cette donnée a été modifiée par un autre utilisateur"
}

10.4 Usage in React

import { useTranslation } from 'react-i18next';

// Component
const { t } = useTranslation('absence');
<h1>{t('leaveRequest.title')}</h1>

// Error handling
const { t: tError } = useTranslation('errors');
const displayError = (apiError: ApiError) => {
  const msg = tError(apiError.code, apiError.params);
  // Falls back to apiError.code if no translation exists
  return msg;
};

10.5 i18next Configuration

// frontend/src/i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

i18n.use(initReactI18next).init({
  lng: 'fr',
  fallbackLng: 'fr',
  ns: ['common', 'shell'],       // Loaded eagerly
  defaultNS: 'common',
  interpolation: { escapeValue: false },
  // Module namespaces loaded lazily when route activates
});

10.6 Backend Rule

The backend NEVER sends translated text. It sends: - Error codes (e.g., ABSENCE_001_OVERLAPPING_LEAVE) - Interpolation params (e.g., { "available": 12 })

The frontend does all translation. This keeps the API language-agnostic — both Papillon and Enterprise frontends (and any future API consumer) get the same response.


11. API Versioning Strategy

ADR-PV-09: Per-module independent versioning

11.1 Rules

  1. Each module versions independently: /api/v2/absence/* can coexist with /api/v1/budget/*
  2. Breaking changes require a new version of THAT module only
  3. Old version stays alive until all clients have migrated
  4. Non-breaking additions (new fields, new endpoints) stay on the current version
  5. Version tracked per module in PROGRESS.md

11.2 What Counts as a Breaking Change

Change Breaking?
Add a new optional field to response No
Add a new endpoint No
Remove a field from response Yes
Rename a field Yes
Change a field's type Yes
Change an endpoint's URL Yes
Add a required field to request body Yes
Change error code format Yes

11.3 Implementation

// V1 controller stays alive
@RestController
@RequestMapping("/api/v1/absence")
class LeaveRequestControllerV1 {
    // Original contract
}

// V2 controller with breaking changes
@RestController
@RequestMapping("/api/v2/absence")
class LeaveRequestControllerV2 {
    // New contract — delegates to same service layer
}

Both controllers share the same service layer. Only the DTO mapping differs.

11.4 Current Version Registry

Module Current Version Notes
Control Plane (/cp/) v1
Budget (/budget/) v1
Absence (/absence/) v1
All others v1 Not yet started

12. Security Architecture

12.1 Authentication Flow

sequenceDiagram
    participant Client as Frontend
    participant KC as Keycloak
    participant API as Spring Boot
    participant Filter as TenantResolutionFilter
    participant Controller

    Client->>KC: POST /token (username, password)
    KC->>Client: Access Token (JWT) + Refresh Token
    Client->>API: GET /api/v1/absence/... + Bearer JWT
    API->>API: Spring Security validates JWT signature
    API->>Filter: Authenticated request
    Filter->>Filter: Extract tenant_id, country_code, roles from JWT
    Filter->>Filter: Lookup db_profile from platform DB
    Filter->>Controller: ScopedValue.where(TENANT_CONTEXT, ctx).run(...)

12.2 JWT Claims Structure

{
  "sub": "user-uuid",
  "tenant_id": "tenant-uuid",
  "country_code": "CI",
  "roles": ["ADMIN", "HR_MANAGER"],
  "email": "[email protected]",
  "name": "Jean Koné",
  "iat": 1741859200,
  "exp": 1741862800
}

12.3 Authorization Model (RBAC)

Role Scope Typical Permissions
SUPER_ADMIN Platform-wide Manage all tenants, billing, platform config
TENANT_ADMIN Tenant-scoped Manage users, roles, org structure, module config
HR_MANAGER Tenant-scoped Manage employees, approve leaves, run payroll
DEPARTMENT_MANAGER OrgUnit-scoped Approve leaves for their team, view team data
EMPLOYEE Self-scoped View own data, submit leave requests
ACCOUNTANT Tenant-scoped View/manage budgets, commitments, expenses
READONLY Tenant-scoped View-only access to assigned modules

12.4 Permission Check Pattern

// In controller
@PreAuthorize("hasPermission('ABSENCE', 'APPROVE_LEAVE')")
@PostMapping("/leave-requests/{id}/approve")
public ApiResponse<LeaveRequestResponse> approve(@PathVariable UUID id) { ... }

// For OrgUnit-scoped roles (e.g., manager can only approve their team)
@PreAuthorize("hasPermission('ABSENCE', 'APPROVE_LEAVE')")
public ApiResponse<LeaveRequestResponse> approve(@PathVariable UUID id) {
    // Service layer checks: is current user the approver for this request's employee?
}

12.5 Tenant Isolation Security Layers

Layer 1: JWT tenant_id claim (cannot be forged — Keycloak signs it)
Layer 2: TenantResolutionFilter validates tenant exists + is active
Layer 3: Every SQL query includes WHERE tenant_id = :tenantId
Layer 4: PostgreSQL RLS (defense-in-depth for Profile A)
Layer 5: CI test validates all @Query include tenant_id

13. Multi-Tenant Strategy (All 4 DB Profiles)

13.1 Profile Overview

Profile Code Tenant Data Location Who Uses It Complexity
Shared SHARED Single DB, tenant_id column on every row SMEs (S1-S3) Medium
Schema-per-tenant SCHEMA Separate schema per tenant in same DB Mid-market (S4) Medium
DB-per-tenant DATABASE Separate database per tenant Large (S5) High
BYO-DB BYO Customer-provided database connection Enterprise (S6) Highest

13.2 DataSource Routing

graph TD
    REQ[Incoming Request]
    FILTER[TenantResolutionFilter]
    CTX[TenantContext<br/>dbProfile = ?]

    REQ --> FILTER
    FILTER --> CTX

    CTX -->|SHARED| SHARED_DS["Default Tenant DataSource<br/>+ tenant_id filter"]
    CTX -->|SCHEMA| SCHEMA_DS["Default Tenant DataSource<br/>+ SET search_path = tenant_xxx"]
    CTX -->|DATABASE| DB_DS["Lookup DataSource from pool<br/>by tenantId"]
    CTX -->|BYO| BYO_DS["Lookup DataSource from<br/>TenantConnectionConfig"]

Profile A: Shared (MVP default)

  • All tenants share one tenantDataSource
  • Every query includes WHERE tenant_id = :tenantId
  • RLS enabled as defense-in-depth
  • Simplest to operate, most cost-effective

Profile B: Schema-per-tenant

  • One PostgreSQL database, one schema per tenant
  • SET search_path = 'tenant_<uuid_short>' at start of each request
  • Flyway runs per schema on tenant creation
  • Better isolation, slightly more complex provisioning

Profile C: DB-per-tenant

  • Separate PostgreSQL database per tenant
  • DataSource instances pooled and cached (Caffeine, 1-hour TTL)
  • TenantDataSourceRouter resolves DataSource by tenantId
  • Full isolation, separate backup/restore per tenant

Profile D: BYO-DB

  • Customer provides connection string, credentials
  • Stored encrypted in platform DB (tenant_connection_config table)
  • DataSource created on-demand, validated before use
  • Customer responsible for backup, but ALTARYS responsible for schema (Flyway)
  • Design for this first — if it works for BYO-DB, it works for all others

13.3 Flyway Migration per Profile

Profile Platform DB Tenant DB
Shared db/migration/ → single platform DB db/tenant-migration/ → single tenant DB
Schema Same db/tenant-migration/ → per-schema (Flyway schema target)
DB-per-tenant Same db/tenant-migration/ → per-database (Flyway URL target)
BYO-DB Same db/tenant-migration/ → customer-provided URL

All four profiles use the same migration scripts (db/tenant-migration/). Only the target changes.


14. Flyway Migration Strategy

14.1 Migration Locations

backend/src/main/resources/
├── db/
│   ├── migration/                    # Platform DB migrations
│   │   ├── V001__create_tenant_table.sql
│   │   ├── V002__extend_tenant_table.sql
│   │   └── ...
│   └── tenant-migration/             # Tenant DB migrations (ALL profiles)
│       ├── V001__create_tenant_settings.sql
│       ├── V002__create_event_publication.sql
│       └── ...

14.2 Migration Naming Convention

  • Forward: V{NNN}__{description}.sql (double underscore)
  • Undo: U{NNN}__{description}.sql
  • NNN is zero-padded 3-digit sequential number
  • Description uses snake_case

14.3 Flyway Configuration

// Platform DB Flyway
Flyway.configure()
    .dataSource(platformDataSource)
    .locations("classpath:db/migration")
    .table("flyway_schema_history")
    .migrate();

// Tenant DB Flyway
Flyway.configure()
    .dataSource(tenantDataSource) // Or per-tenant DataSource for Profile C/D
    .locations("classpath:db/tenant-migration")
    .table("flyway_schema_history_tenant") // Avoid collision
    .baselineOnMigrate(true)
    .baselineVersion("0")
    .migrate();

14.4 Rules for New Migrations

  1. Never modify an existing migration — always create a new one
  2. Include tenant_id in every tenant DB table — even if it feels redundant for Profile C/D (keeps queries portable)
  3. Use IF NOT EXISTS for idempotent migrations (especially important for BYO-DB where migrations may be re-run)
  4. Test migrations on empty DB AND on DB with existing data — prevents "works on new tenants, breaks on existing ones"

15. Performance & Caching

15.1 Caching Architecture (ADR-04)

graph LR
    subgraph "L1: In-Process (Caffeine)"
        PERM[Permissions<br/>60s TTL]
        COUNTRY[Country Config<br/>24h TTL]
    end

    subgraph "L2: Distributed (Redis)"
        TCONF[Tenant Config<br/>5min TTL]
        MGATE[Module Gating<br/>5min TTL]
        SESSION[User Sessions<br/>JWT lifetime]
    end

    subgraph "L3: Client (IndexedDB)"
        REFDATA[Reference Data<br/>leave types, org units]
        QUEUE[Offline Queue<br/>mutations]
    end

15.2 Cache Invalidation

Cache Invalidation Trigger
Tenant config TenantStatusChanged
Module gating ModuleActivated / ModuleDeactivated
Permissions UserRoleChanged
Country config Manual (reference data changes rarely)
Client-side ref data Background refresh on online event

15.3 Connection Pooling

Profile Strategy
Shared Single HikariCP pool (max 20 connections)
Schema Single pool + SET search_path per request
DB-per-tenant Pool-per-tenant (max 5 each), Caffeine cache of DataSources (1h TTL, max 50)
BYO-DB Pool-per-tenant (max 3 each), created on-demand, evicted after 30min idle

15.4 Query Performance Rules

  1. Every table with tenant_id has a composite index: (tenant_id, <natural-key>)
  2. Dashboard aggregations use on-the-fly SQL (ADR-15) — no materialized views at MVP scale
  3. Cursor-based pagination (ADR-12) — no OFFSET queries
  4. EXPLAIN ANALYZE on every query that touches > 100 rows in tests

16. Shared Test Infrastructure

ADR-PV-10: Shared test-support module

16.1 Test Support Package

backend/src/test/java/com/altarys/papillon/testsupport/
├── AbstractTenantIntegrationTest.java   # Base class for all integration tests
├── TestTenantFactory.java               # Creates test tenants with realistic data
├── TestEmployeeFactory.java             # Creates test employees
├── MockJwtDecoder.java                  # Stubs Keycloak JWT validation
├── TestEventCollector.java              # Captures published events for assertions
├── TestTenantContextHelper.java         # Sets TenantContext in tests
├── TenantIsolationVerifier.java         # Verifies no cross-tenant data leaks
└── QueryTenantIdScanner.java            # CI test: scans @Query for tenant_id

16.2 AbstractTenantIntegrationTest

// Base class for ALL module integration tests
@SpringBootTest
@Import(TestcontainersConfig.class)
public abstract class AbstractTenantIntegrationTest {

    @Autowired TestTenantFactory tenantFactory;
    @Autowired TestEventCollector eventCollector;

    protected TenantContext testTenant;

    @BeforeEach
    void setUpTenant() {
        testTenant = tenantFactory.createAndProvision("TEST-CORP", "CI", "SHARED");
        TenantContextHolder.setFallback(testTenant);
        eventCollector.clear();
    }

    @AfterEach
    void tearDown() {
        TenantContextHolder.clearFallback();
    }

    /** Run a block within a specific tenant context */
    protected void withTenant(TenantContext ctx, Runnable block) {
        TenantContextHolder.setFallback(ctx);
        try {
            block.run();
        } finally {
            TenantContextHolder.setFallback(testTenant);
        }
    }

    /** Assert that an event of a given type was published */
    protected <T> T assertEventPublished(Class<T> eventType) {
        return eventCollector.assertPublished(eventType);
    }
}

16.3 TestcontainersConfig (Existing)

// Already exists at: backend/src/test/java/com/altarys/papillon/TestcontainersConfig.java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
    static PostgreSQLContainer<?> platformDb = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("altarys_platform_test");
    static PostgreSQLContainer<?> tenantDb = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("altarys_tenant_test");

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        // Maps spring.datasource.* → platformDb
        // Maps altarys.datasource.tenant.* → tenantDb
    }
}

16.4 MockJwtDecoder

// Stubs Keycloak for tests — no real Keycloak container needed
@TestConfiguration
public class MockJwtDecoder {

    @Bean
    JwtDecoder jwtDecoder() {
        return token -> {
            // Parse token as a simple JSON, return mock Jwt
            // Used with TestJwtConfig.createToken(tenantId, userId, roles)
        };
    }
}

16.5 QueryTenantIdScanner (CI Test)

// Runs in CI to enforce ADR-PV-03 (tenant_id in every query)
@Test
void allQueriesIncludeTenantId() {
    // 1. Scan classpath for all interfaces extending CrudRepository
    // 2. Find all @Query annotations
    // 3. Assert each contains "tenant_id" in the SQL
    // 4. Fail with clear message listing violations
}

16.6 Test Patterns Per Module

Test Type Base Class What It Tests
Unit test (domain) None State machines, validators, pure logic
Service integration AbstractTenantIntegrationTest Business logic + DB + events
Controller integration AbstractTenantIntegrationTest + MockMvc API contracts, auth, validation
Tenant isolation AbstractTenantIntegrationTest Create 2 tenants, verify no data leaks
@Query scan Standalone JUnit All queries include tenant_id

17. Local Dev Environment

17.1 docker-compose.yml

# docker-compose.yml (project root)
services:
  platform-db:
    image: postgres:16-alpine
    container_name: altarys-platform-db
    environment:
      POSTGRES_DB: altarys_platform
      POSTGRES_USER: altarys
      POSTGRES_PASSWORD: altarys
    ports:
      - "5432:5432"
    volumes:
      - platform-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U altarys -d altarys_platform"]
      interval: 5s
      timeout: 3s
      retries: 5

  tenant-db:
    image: postgres:16-alpine
    container_name: altarys-tenant-db
    environment:
      POSTGRES_DB: altarys_tenant
      POSTGRES_USER: altarys
      POSTGRES_PASSWORD: altarys
    ports:
      - "5433:5432"
    volumes:
      - tenant-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U altarys -d altarys_tenant"]
      interval: 5s
      timeout: 3s
      retries: 5

  keycloak:
    image: quay.io/keycloak/keycloak:26.1
    container_name: altarys-keycloak
    command: start-dev --import-realm
    environment:
      KC_DB: dev-mem
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    ports:
      - "8180:8080"
    volumes:
      - ./infrastructure/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
    healthcheck:
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n' >&3"]
      interval: 10s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    container_name: altarys-redis
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  minio:
    image: minio/minio:latest
    container_name: altarys-minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: altarys
      MINIO_ROOT_PASSWORD: altarys123
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - minio-data:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  platform-db-data:
  tenant-db-data:
  minio-data:

17.2 Environment Variables

# Backend (.env or application-local.yml)
PLATFORM_DB_URL=jdbc:postgresql://localhost:5432/altarys_platform
PLATFORM_DB_USERNAME=altarys
PLATFORM_DB_PASSWORD=altarys
TENANT_DB_URL=jdbc:postgresql://localhost:5433/altarys_tenant
TENANT_DB_USERNAME=altarys
TENANT_DB_PASSWORD=altarys
KEYCLOAK_ISSUER_URI=http://localhost:8180/realms/altarys
SERVER_PORT=8080

# Frontend (.env)
VITE_API_BASE_URL=http://localhost:8080
VITE_KEYCLOAK_URL=http://localhost:8180
VITE_KEYCLOAK_REALM=altarys
VITE_KEYCLOAK_CLIENT_ID=papillon-web

17.3 Verification Command

# Start all services
docker compose up -d

# Wait for health checks
docker compose ps  # All should be "healthy"

# Start backend
./gradlew bootRun

# Start frontend (separate terminal)
cd frontend && pnpm dev

# Verify
curl http://localhost:8080/actuator/health
# → {"status": "UP"}

18. Frontend Architecture

18.1 Design System

  • Technology: Shadcn/ui + Tailwind CSS (ADR-06: Claude frontend-design compatibility, small bundle, CSS variable theming for dual-brand)
  • Catalogue: Storybook 10 (pnpm storybook → localhost:6006)
  • Theme injection: CSS variables in frontend/src/theme/ — brand values never hardcoded
  • Dual-brand support: Papillon (warm, mobile-first) and Enterprise (corporate, desktop-primary) themes via CSS variable override

18.2 Module Structure (Frontend)

frontend/src/modules/<module-name>/
├── components/          # Module-specific UI components
├── hooks/               # Custom React hooks (useLeaveRequests, etc.)
├── services/            # API client functions
├── types/               # TypeScript types for this module
├── pages/               # Route pages
└── i18n/
    └── fr.json          # Module translations

18.3 Shared Components

packages/shared-components/
├── src/
│   ├── components/      # Design system components (Button, Input, Card, etc.)
│   ├── hooks/
│   │   └── useOfflineQueue.ts  # Shared offline sync hook (ADR-PV-05)
│   ├── contexts/        # Shared React contexts
│   └── theme/           # CSS custom properties (brand-agnostic)

18.4 Data Fetching

  • TanStack Query (@tanstack/react-query) for all API calls
  • Stale-while-revalidate pattern for GET requests
  • Mutations go through useOfflineQueue() for offline support
  • Query keys include tenantId for cache isolation
// Typical data fetching pattern
const { data, isLoading } = useQuery({
  queryKey: ['absence', 'leave-requests', { status: 'PENDING' }],
  queryFn: () => leaveRequestService.list({ status: 'PENDING' }),
  staleTime: 30_000, // 30s
});

19. Vertical Slice Decomposition

These slices implement the shared platform infrastructure. They are Tier 0 for the entire platform — all module-specific work depends on them.

Tier 0 — Platform Foundation (Serial)

PV-001: Local Dev Environment (docker-compose)

Scope: Create docker-compose.yml with all external dependencies, verify full local startup.

Files: - docker-compose.yml (new) - infrastructure/keycloak/realm-export.json (new — minimal Keycloak realm) - backend/src/main/resources/application-local.yml (new — local profile overrides) - .env.example (new)

Estimated complexity: S Dependencies: None Acceptance criteria: 1. docker compose up -d starts all 5 services (2× PostgreSQL, Keycloak, Redis, MinIO) 2. All containers reach healthy status within 60s 3. ./gradlew bootRun connects to all services without error 4. curl localhost:8080/actuator/health returns UP


PV-002: Shared Event Catalog Module

Scope: Create the shared event package with all cross-module event records from PROGRESS.md Event Contracts Registry.

Files: - backend/src/main/java/com/altarys/papillon/events/ (new package, ~25 record classes) - One Java record per event from PROGRESS.md Event Contracts Registry

Estimated complexity: M Dependencies: PV-001 Acceptance criteria: 1. All events from PROGRESS.md Event Contracts Registry exist as Java records 2. All events are immutable records with proper field types (UUID, Instant, String, etc.) 3. Spring Modulith architecture test passes (events are in shared kernel) 4. No circular dependencies


PV-003: Module Gating Interceptor

Scope: Implement ModuleGatingInterceptor + ModuleGatingService with Redis caching.

Files: - backend/src/main/java/com/altarys/papillon/platform/config/ModuleGatingInterceptor.java (new) - backend/src/main/java/com/altarys/papillon/platform/tenant/service/ModuleGatingService.java (new) - backend/src/main/java/com/altarys/papillon/platform/config/ModuleNotActivatedException.java (new) - Update GlobalExceptionHandler.java — add handler for ModuleNotActivatedException - Tests: ModuleGatingInterceptorTest.java, ModuleGatingServiceTest.java

Estimated complexity: M Dependencies: PV-001 Acceptance criteria: 1. Request to /api/v1/absence/* returns 403 when ABSMGT not active for tenant 2. Request to /api/v1/cp/* always passes (CP not gated) 3. Module activation status cached in Redis (5min TTL) 4. Cache invalidated on ModuleActivated / ModuleDeactivated


PV-004: Audit Event Listener

Scope: Implement the Audit module's event listener that captures all domain events as audit entries.

Files: - backend/src/main/java/com/altarys/papillon/platform/audit/listener/AuditEventListener.java (new) - backend/src/main/java/com/altarys/papillon/platform/audit/repository/AuditEntryRepository.java (new or update existing) - backend/src/main/resources/db/tenant-migration/V003__create_audit_entry.sql (new) - Tests: AuditEventListenerTest.java

Estimated complexity: M Dependencies: PV-002 (needs event records) Acceptance criteria: 1. Publishing any domain event creates an audit_entry row 2. Audit entry captures: tenant_id, entity_type, entity_id, action, user_id, timestamp, JSON payload 3. Audit entries partitioned by timestamp (monthly) 4. Audit entries include tenant_id (isolation verified)


PV-005: Shared Test Infrastructure

Scope: Create the test-support package with base classes, factories, and CI enforcement tests.

Files: - backend/src/test/java/com/altarys/papillon/testsupport/AbstractTenantIntegrationTest.java (new) - backend/src/test/java/com/altarys/papillon/testsupport/TestTenantFactory.java (new) - backend/src/test/java/com/altarys/papillon/testsupport/TestEventCollector.java (new) - backend/src/test/java/com/altarys/papillon/testsupport/TestTenantContextHelper.java (new) - backend/src/test/java/com/altarys/papillon/testsupport/QueryTenantIdScanner.java (new — CI test) - Refactor existing tests to extend AbstractTenantIntegrationTest

Estimated complexity: M Dependencies: PV-001 Split candidate: Could split into PV-005a (base classes + factories) and PV-005b (QueryTenantIdScanner CI test) Acceptance criteria: 1. AbstractTenantIntegrationTest provides TenantContext setup/teardown, event collector, tenant factory 2. Existing tests refactored to use new base class (all 89 still pass) 3. QueryTenantIdScanner scans all @Query annotations, fails if any tenant DB query lacks tenant_id 4. TestEventCollector captures events and provides assertion methods


Tier 1 — Platform Services (Parallel after Tier 0)

PV-006: Offline Sync Hook (useOfflineQueue)

Scope: Implement the shared offline sync hook in packages/shared-components/.

Files: - packages/shared-components/src/hooks/useOfflineQueue.ts (new) - packages/shared-components/src/hooks/useOfflineQueue.test.ts (new) - packages/shared-components/src/components/SyncStatusIndicator/ (new) - packages/shared-components/src/components/ConflictDialog/ (new) - packages/shared-components/src/lib/idb.ts (new — IndexedDB wrapper)

Estimated complexity: L Dependencies: PV-001 Split candidate: Could split into PV-006a (queue + IndexedDB) and PV-006b (conflict UI + sync indicator) Acceptance criteria: 1. enqueue() persists mutation to IndexedDB 2. Queue processes FIFO when navigator.onLine is true 3. Failed items retry with exponential backoff (1s, 2s, 4s) 4. 409 responses trigger conflict resolver callback 5. syncStatus reflects current queue state 6. SyncStatusIndicator renders correct dot color + French label


PV-007: i18n Foundation

Scope: Set up i18n infrastructure with namespace lazy-loading, error code mapping, and common translations.

Files: - frontend/src/i18n/config.ts (update — add lazy loading, namespace support) - frontend/src/locales/fr/common.json (new — shared buttons, nav, dates, statuses) - frontend/src/locales/fr/errors.json (new — error code → French message mapping) - frontend/src/locales/fr/shell.json (update existing) - frontend/src/lib/errorDisplay.ts (new — maps ApiError code → translated string)

Estimated complexity: S Dependencies: PV-001 Acceptance criteria: 1. useTranslation('common') returns shared French strings 2. useTranslation('errors') maps error codes to French messages 3. Module namespaces load lazily (only when route activates) 4. Missing translation falls back to error code (never crashes)


PV-008: RLS Defense-in-Depth (Profile A)

Scope: Add PostgreSQL Row-Level Security policies to tenant DB tables for Profile A (shared DB).

Files: - backend/src/main/resources/db/tenant-migration/V004__enable_rls.sql (new) - Update TenantResolutionFilter — add SET LOCAL app.current_tenant_id for Profile A - Tests: RlsIsolationTest.java

Estimated complexity: M Dependencies: PV-001, PV-005 Acceptance criteria: 1. RLS policies exist on all tenant DB tables 2. Direct SQL query without SET LOCAL returns zero rows (even if data exists) 3. Query with correct SET LOCAL returns only that tenant's data 4. RLS active only for Profile A (SHARED) — no-op for other profiles


20. Architectural Decision Records

Summary of all decisions made in this document:

# Decision Choice Rationale
ADR-PV-01 TenantContext propagation ScopedValue (Java 25) + ThreadLocal fallback Immutable, virtual-thread safe, no leaking. Fallback for @Scheduled/tests/Flyway.
ADR-PV-02 Cross-module event contracts Shared event module (com.altarys.papillon.events) Shared kernel pattern. Thin event catalog that all modules depend on. Enables future microservice extraction.
ADR-PV-03 Tenant ID enforcement Convention + RLS + CI test (3 layers) Explicit WHERE tenant_id in every query. RLS as defense-in-depth. CI test as automated verification. No AOP magic.
ADR-PV-04 Error handling Global @ControllerAdvice + module error codes One handler guarantees uniform { data, meta, errors } envelope. Module error codes are namespaced (ABSENCE_001, BUDGET_001).
ADR-PV-05 Offline sync framework Shared useOfflineQueue() hook One implementation, consistent UX, every module imports the same hook. IndexedDB + FIFO + retry + conflict UI.
ADR-PV-06 Audit trail integration Application Events (audit as consumer) Zero coupling. Modules already publish events (ADR-14). Audit module listens. Guaranteed delivery via EVENT_PUBLICATION.
ADR-PV-07 i18n architecture Namespace-per-module JSON, frontend-only Backend sends error codes + params. Frontend translates. API stays language-agnostic. Lazy-loaded namespaces for bundle size.
ADR-PV-08 Module gating Spring ModuleGatingInterceptor Centralized URL-prefix → module-code mapping. Impossible to forget. Redis-cached. One config to maintain.
ADR-PV-09 API versioning Per-module independent versioning Modules evolve independently. No big-bang v2. Industry standard (Stripe, Twilio).
ADR-PV-10 Shared test infrastructure test-support module DRY base classes, factories, event collector, CI enforcement. All module tests extend same foundation.

21. Technical Risks & Mitigations

# Risk Impact Likelihood Mitigation
R1 ScopedValue doesn't propagate across @Async TenantContext lost in async operations Medium ThreadLocal fallback + async task decorator that copies context
R2 BYO-DB DataSource pool exhaustion Connection starvation for BYO tenants Low (MVP) Max 3 connections per BYO tenant, 30min idle eviction, circuit breaker
R3 Cross-tenant data leak via missing tenant_id Critical security breach Low (CI test catches) 3-layer enforcement (convention + RLS + CI scan). RLS is the last line of defense.
R4 Event listener failure causes audit gaps Missing audit entries Low (guaranteed delivery) Spring Modulith EVENT_PUBLICATION table. Failed events retry automatically. Monitoring on uncompleted publications.
R5 IndexedDB quota exceeded on mobile Offline queue full, mutations lost Low Queue max size (100 items). Old items surface "sync now" warning. Background Sync as safety net.
R6 Module gating cache stale after activation User gets 403 for 5 minutes after activating a module Medium Cache invalidation via ModuleActivated. Fallback: user refresh. Short TTL (5min).
R7 Keycloak realm drift between dev environments JWT claim format inconsistent Medium realm-export.json in repo, auto-imported by docker-compose. Test JwtTenantExtractor with known claims.
R8 Flyway migration order conflict across modules Two modules create same V003__ Low Module-prefixed migration numbers: V003__absmgt_create_leave_request.sql. Documented in standards.
R9 i18n error code mapping incomplete User sees raw error code instead of French message Medium Fallback: display code as-is. CI test: ensure every error code constant has a matching entry in errors.json.
R10 Dual DataSource test isolation Test data leaks between platform and tenant DB assertions Low TestcontainersConfig creates separate containers. @Transactional test rollback per datasource.