Aller au contenu

Architecture - tenant-admin (TENANT) module - V1

Module prefix: TENANT · Plane: Control · Version scope: [V1] · Country scope V1: CI only Author: ARCHITECT · Inputs: docs/prd/tenant-admin-prd.md, docs/architecture/platform-saas-blueprint.md §4 + §5.1, docs/standards/multi-datasource-patterns.md, docs/standards/database-guidelines.md, docs/standards/api-guidelines.md, docs/standards/design-system.md, V1 PRD §18 (audit) and §27 (data dictionary). Status: Awaiting founder approval (workflow stage 4).


0. V0 Envelope Deltas (ajout 2026-05-21)

Ce bloc est strictement additif : il décrit comment cette architecture V1 du module TENANT est livrée progressivement à travers les sous-versions V0 (v0.0.0 -> v0.1.0). Tout le contenu ci-dessous (§1 à §N) reste la cible V1. Les deltas ci-dessous précisent ce qui est implémenté à quel moment, et ce qui reste inerte dans le code source pendant la phase V0. Référence : docs/prd/prd-v0.md § 4 (ligne TENANT), § 5 (anchors transverses, en particulier D2 datasource resolver + D9 Flyway multi-profil) et § 8 (mapping stories).

0.1 Découpage par sous-version

Sous-version Livrable TENANT vs cette architecture V1 Story IDs
v0.0.0 Table tenant (+ country_profile seedée CI) + flow de création tenant + provisionnement automatique du CABINET_ADMIN initial. Profil DB unique : SHARED. Le TenantContextHolder + le TenantAwareDataSourceResolver (anchor D2) sont câblés Day 1 mais ne renvoient que la datasource partagée. Emission de TenantOnboarded consommée par AUTH (cf. identity-arch). TENANT-001
v0.0.2 Table agency + CRUD + binding users.agency_scope. Introduction de la clé agency_id sur les flux downstream (NOTIF queuing, OP routing). Pas encore de bascule d'état tenant côté SUPENDED/REACTIVATED (reportée plus tard). TENANT-002
v0.0.4 Profil DB BYO activé : le resolver (D2) gagne sa branche BYO, le runner Flyway multi-profil (D9) applique le même schéma sur la BYO DB du tenant que sur la SHARED DB. Greenfield only : pas de migration SHARED -> BYO d'un tenant existant. Cible : NSIA. AUTH user tables RESTENT sur la platform DB (cf. AUTH-ADR-01). TENANT-003
v0.1.0 Hardening : événements TenantSuspended / TenantReactivated / TenantSoftDeleted émis et consommés (AUTH applique hard-cut + invalidation session, anchor operator_session_force_killed). Best-effort-immediate sur les durées de retention (AC-09.1) acté ; la mise en œuvre formelle reste V1. (couvert par OP-005 + AUTH-006)

0.2 Verrouillé Day 1 (V0.0.0) - non négociable

  • Schéma tenant complet dès v0.0.0 : id, legal_name, commercial_name, country_code (NOT NULL, no implicit default), db_profile (default 'SHARED'), agrement_number, logo_object_key, primary_color, support_email, status, audit_retention_years (>= 10), min_auth_level (default 1), created_at, updated_at. Pas de colonne ajoutée par migration entre v0.0.0 et v0.0.4 sur les tables tenant-scopées.
  • country_profile lookup table : seedée CI only. Colonnes pré-réservées pour notif_provider_sms, notif_provider_wa, payment_provider_default. Pas de colonnes pré-réservées pour FNE / e-invoicing (BILL est hors V0 ; ces colonnes arrivent avec la story V1 BILL).
  • group_id NULLable : NON présent en v0.0.0 (V2 l'ajoute). Aucune contrainte UNIQUE sur legal_name ou commercial_name ne doit bloquer son ajout futur.
  • TenantAwareDataSourceResolver câblé dès v0.0.0 sur le chemin de chaque repository tenant-scopé, même quand il ne renvoie qu'une datasource. C'est ce câblage qui rend v0.0.4 (BYO) non-disruptif.
  • Liquibase / Flyway multi-profil : runner câblé Day 1 même s'il n'a qu'un seul target en v0.0.0 (anchor D9).

0.3 Hors V0 (renvoyé à V1, V2 ou V3)

  • Profils SCHEMA et DATABASE : V3 (CLAUDE.md DB profile taxonomy). Aucune préparation V0 sauf le resolver agnostique du nombre de datasources.
  • Migration SHARED -> BYO d'un tenant existant : reportée à V2. v0.0.4 reste greenfield.
  • group_id + relations de holding : reportées à V2.
  • Console BILL pour ce tenant : entièrement hors V0 (cf. banner V0 de billing-prd.md). Les hooks invoice / FNE / DGI n'apparaissent dans ce code que quand le chantier V1 BILL démarre.
  • Soft-delete + archival des données tenant post-TenantSoftDeleted : reporté à V1 (l'évènement est émis dès v0.1.0 mais la purge réelle attend V1).

Aucun statut INCERTAIN - avocat requis ou PRÉLIMINAIRE du memo n'est levé par le découpage V0. Les notes [LEGAL-REVIEW] héritées du PRD restent en vigueur sur les stories correspondantes.


1. System Context

TENANT is the bootstrap module of the Control Plane. Every other module in the platform - AUTH, BILL, AUDIT, NOTIF, ING, CP, OP, PAY - relies on a tenant row existing, on a country profile being resolvable from it, and on the lifecycle state being authoritative. There is no flow in the system that does not start with TenantContext.tenantId.

1.1 Position in the platform

flowchart LR
    subgraph CP["CONTROL PLANE"]
        TENANT["tenant-admin (TENANT)<br/>· tenant + agency CRUD<br/>· lifecycle state machine<br/>· country_profile lookup<br/>· compliance docs<br/>· BYO-DB config"]
        AUTH[identity / AUTH]
        BILL[billing / BILL]
        AUDIT[audit-log / AUDIT]
        NOTIF[notifications / NOTIF]
    end
    subgraph AP["APPLICATION PLANE"]
        ING[ingestion / ING]
        CPP[customer-portal / CP]
        OP[operator-console / OP]
        PAY[payment / PAY]
    end
    subgraph INFRA["Shared infra"]
        TADS[TenantAwareDataSource]
        EVT[Spring App Events]
        CTX[TenantContext]
    end

    TENANT -- TenantOnboarded --> AUTH
    TENANT -- TenantOnboarded --> BILL
    TENANT -- TenantOnboarded --> NOTIF
    TENANT -- TenantSuspended --> AUTH
    TENANT -- TenantSuspended --> CPP
    TENANT -- TenantSuspended --> PAY
    TENANT -- TenantSuspended --> OP
    TENANT -- TenantSoftDeleted --> ING
    TENANT -- AgencyDisabled --> OP
    TENANT -- AgencyDisabled --> ING
    TENANT -- AgencyDisabled --> AUTH
    TENANT -- TenantSettingsChanged --> OP
    TENANT -- TenantSettingsChanged --> AUTH
    TENANT -- TenantSettingsChanged --> NOTIF
    TENANT -- writes --> AUDIT
    AUTH -- KeycloakAdminClient --> TENANT
    INFRA --- TENANT

1.2 Cross-module contracts (summary)

Contract Direction Counterparty Detail
TenantOnboarded OUT AUTH/BILL/NOTIF/AUDIT {tenantId, countryCode, plan, primaryContact, defaultAgencyId, byoDb}
TenantSuspended OUT AUTH/CP/PAY/OP/AUDIT {tenantId, reasonCode, suspendedAt}
TenantReactivated OUT AUTH/CP/PAY/OP/AUDIT {tenantId, reactivatedAt}
TenantSoftDeleted OUT ING/CP/AUDIT {tenantId, deletedAt} - triggers PII purge
AgencyDisabled OUT OP/ING/AUTH/AUDIT {tenantId, agencyId, disabledAt}
TenantSettingsChanged OUT OP/AUTH/NOTIF/AUDIT {tenantId, changedKeys[]}
KeycloakAdminClient.createClient(...) IN from this module to AUTH Step 3 of provisioning (TR-002).
MinioObjectStore.put/get/list(...) IN shared infra Compliance docs + tenant logo storage.
Encryptor.envelope(plaintext) IN shared infra Wraps BYO-DB credentials (KEK in env, DEK per-tenant).
AuditService.log(event, ...) IN from this module to AUDIT Best-effort; never on the request critical path (NFR-09).
CountryProfileService.lookup(cc) IN/OUT this module owns it; PAY/BILL/NOTIF read Seeded read-only in V1 (CI only).

1.3 Events produced - payload schemas

// All events declared in com.altarys.papillon.pcs.controlplane.tenant.events
public record TenantOnboarded(
    String tenantId, String countryCode, String plan,
    PrimaryContact primaryContact, String defaultAgencyId, boolean byoDb,
    Instant occurredAt) implements DomainEvent {}

public record TenantSuspended(
    String tenantId, String reasonCode, Instant suspendedAt) implements DomainEvent {}

public record TenantReactivated(
    String tenantId, Instant reactivatedAt) implements DomainEvent {}

public record TenantSoftDeleted(
    String tenantId, Instant deletedAt) implements DomainEvent {}

public record AgencyDisabled(
    String tenantId, String agencyId, Instant disabledAt) implements DomainEvent {}

public record TenantSettingsChanged(
    String tenantId, List<String> changedKeys, Instant occurredAt) implements DomainEvent {}

Events are dispatched via Spring ApplicationEventPublisher. For the cross-tenant downstream reactions that must survive a crash (e.g. PII purge, FNE invoice seed), listeners use @TransactionalEventListener(phase = AFTER_COMMIT) AND persist the event in spring-modulith-events-jdbc (already in the blueprint §3.5) so a worker re-delivers on failure.


2. Data Model

2.1 Database split (per multi-datasource-patterns.md)

Entity Database Justification
tenant Platform Tenant directory - read by TenantAwareDataSource resolver before any tenant DB exists for routing. Chicken-and-egg: cannot live in a tenant DB.
country_profile Platform Global reference data shared across all tenants.
tenant_provisioning_run Platform Step-state for idempotent provisioning retry (AC-02.2). Per-tenant rows but read by the orchestrator before tenant routing is valid.
agency Tenant Tenant-scoped; lives in tenant's DB (own DB if BYO-DB).
tenant_settings Tenant Same as above.
tenant_compliance_doc Tenant Same as above; the binary lives in MinIO, only metadata in DB.

Rule of thumb followed: data answering "who are our tenants?" → platform DB. Data answering "what does this tenant's business look like?" → tenant DB.

2.2 ER diagram

erDiagram
    tenant ||--o| tenant_settings : has
    tenant ||--o{ agency : has
    tenant ||--o{ tenant_compliance_doc : has
    tenant ||--o{ tenant_provisioning_run : has
    country_profile ||--o{ tenant : "by country_code"

    tenant {
        UUID id PK
        TEXT slug UK
        TEXT legal_name
        TEXT commercial_name
        CHAR2 country_code "FK country_profile, default 'CI'"
        UUID group_id "NULLABLE - V2"
        TEXT agrement_number
        TEXT ncc UK_active "Numero Compte Contribuable"
        TEXT address_line1
        TEXT address_line2
        TEXT address_city
        TEXT address_postal_code
        CHAR2 address_country
        TEXT primary_contact_name
        TEXT primary_contact_email
        TEXT primary_contact_phone "E.164"
        TEXT support_email
        TEXT logo_object_key "MinIO key"
        TEXT primary_color "#RRGGBB"
        TEXT plan "STARTER|GROWTH|ENTERPRISE"
        BOOLEAN byo_db
        TEXT byo_db_jdbc_url "encrypted"
        TEXT byo_db_username "encrypted"
        BYTEA byo_db_password_ciphertext
        INT byo_db_pool_size
        TEXT status "PENDING|ACTIVE|SUSPENDED|DELETED"
        TEXT last_provisioning_error
        TIMESTAMPTZ activated_at
        TIMESTAMPTZ suspended_at
        TEXT suspension_reason_code
        TEXT suspension_reason_text
        TIMESTAMPTZ deleted_at
        TIMESTAMPTZ created_at
        TIMESTAMPTZ updated_at
    }

    country_profile {
        CHAR2 country_code PK
        TEXT display_name_fr
        CHAR3 currency_code
        TEXT phone_dial_code
        JSONB payment_providers
        TEXT e_invoicing_provider "FNE_CI in V1"
        TEXT data_authority "ARTCI in V1"
        JSONB working_calendar
    }

    tenant_provisioning_run {
        UUID id PK
        UUID tenant_id FK
        TEXT step "CP_BIND|BYO_DB|KEYCLOAK|DEFAULT_AGENCY|EVENT_EMIT"
        TEXT status "PENDING|RUNNING|DONE|FAILED"
        TEXT last_error
        INT attempt_count
        TIMESTAMPTZ started_at
        TIMESTAMPTZ completed_at
    }

    agency {
        UUID id PK
        UUID tenant_id FK
        TEXT code UK_per_tenant
        TEXT name
        TEXT city
        TEXT address_line1
        TEXT phone
        TEXT manager_name
        TEXT status "ACTIVE|INACTIVE"
        TIMESTAMPTZ created_at
        TIMESTAMPTZ updated_at
    }

    tenant_settings {
        UUID tenant_id PK_FK
        INT renewal_window_days "default 30, [7..90]"
        INT min_auth_level "V1=1"
        TEXT default_channel "WHATSAPP|SMS"
        TEXT whatsapp_template_id
        INT audit_retention_years_override
        INT source_file_retention_days_override
        INT quittance_retention_years_override
        TIMESTAMPTZ updated_at
    }

    tenant_compliance_doc {
        UUID id PK
        UUID tenant_id FK
        TEXT doc_type "DPA|AGREMENT|FNE_REGISTRATION|OTHER"
        INT version
        BOOLEAN is_active
        TEXT object_key "MinIO key"
        CHAR64 sha256
        DATE signed_at
        DATE expires_at
        UUID uploaded_by
        TIMESTAMPTZ uploaded_at
    }

2.3 Indexing strategy

Table Indexes
tenant PK(id); UK(slug); partial UK UNIQUE (ncc) WHERE status <> 'DELETED' (TR-001 dup-NCC rule, but soft-deleted tenants release NCC for re-onboarding); IDX(country_code); IDX(status); IDX(suspended_at) for the daily 90-day grace job. No unique constraint on legal_name (V2 group + foreign-subsidiary forward-compat).
country_profile PK(country_code).
tenant_provisioning_run PK(id); IDX(tenant_id, step); UNIQUE(tenant_id, step) - exactly one row per (tenant, step) for idempotency.
agency PK(id); UK(tenant_id, lower(code)); IDX(tenant_id, status); CHECK enforced at app level: at least one ACTIVE agency per tenant (TR-007 - DB has IDX (tenant_id) WHERE status='ACTIVE' to make the count cheap).
tenant_settings PK(tenant_id).
tenant_compliance_doc PK(id); UK(tenant_id, doc_type, version); partial UK UNIQUE (tenant_id, doc_type) WHERE is_active = TRUE; IDX(expires_at) for daily 30-day-warning job (AC-06.2).

2.4 V2 forward-compat (locked from PRD §B.5)

  • tenant.country_code exists from day one (default 'CI' only via app validation, NOT a DB default - DB allows other ISO-2 values so V2 doesn't need a migration).
  • tenant.group_id UUID NULL - exists in V1 but unused. No FK constraint in V1 (V2 adds group table + FK).
  • No UNIQUE involving legal_name or slug extends across country_code or group_id.
  • All tenant-scoped tables (agency, tenant_settings, tenant_compliance_doc) carry tenant_id and have NO foreign key to tenant across DB boundaries (the FK exists in the shared-DB profile only; for BYO-DB, the rows live in a different DB and the relationship is enforced at the application layer via TenantContext).

3. API Design

All endpoints live under /api/control/tenants/... (platform-admin) and /api/tenant/me/... (tenant-scoped self-service). Response envelope follows api-guidelines.md: { data, meta?, errors? }. Versioning is URL-based.

3.1 Endpoint surface

# Verb Path Caller Auth Purpose
1 POST /api/control/tenants Platform admin Keycloak role papillon-platform-admin Create (PENDING).
2 POST /api/control/tenants/{id}/provision Platform admin Same Trigger or retry provisioning (idempotent).
3 GET /api/control/tenants?status=&q=&cursor=&limit= Platform admin Same List (cross-tenant; audited PLATFORM_ADMIN_QUERY).
4 GET /api/control/tenants/{id} Platform admin Same Detail.
5 PATCH /api/control/tenants/{id}/identity Platform admin Same Edit name / NCC / agrément (audited).
6 POST /api/control/tenants/{id}/suspend Platform admin Same Body {reasonCode, reasonText}.
7 POST /api/control/tenants/{id}/reactivate Platform admin Same -
8 POST /api/control/tenants/{id}/compliance-docs Platform admin Same Multipart upload (one of DPA / AGREMENT / FNE_REGISTRATION / OTHER).
9 GET /api/control/tenants/{id}/compliance-docs Platform admin Same List versions.
10 GET /api/control/tenants/{id}/compliance-docs/{docId}/download Platform admin Same 302 → short-TTL signed MinIO URL.
11 GET /api/tenant/me Tenant admin Keycloak role tenant-admin Read own tenant + settings.
12 PATCH /api/tenant/me/branding Tenant admin Same logo, primary_color, support_email.
13 PATCH /api/tenant/me/settings Tenant admin Same Bounded settings (TR-008, TR-009).
14 GET /api/tenant/me/agencies?status=&cursor=&limit= Tenant admin / Operator / Viewer Same role family List own agencies.
15 POST /api/tenant/me/agencies Tenant admin Same Create.
16 PATCH /api/tenant/me/agencies/{agencyId} Tenant admin Same Edit.
17 POST /api/tenant/me/agencies/{agencyId}/disable Tenant admin Same Soft-disable; TR-007 enforced server-side.
18 POST /api/tenant/me/agencies/{agencyId}/reactivate Tenant admin Same -

3.2 Pagination

Cursor-based per api-guidelines.md. Cursor is an opaque base64 of (sort_key, id). List endpoints accept ?cursor=&limit=N (default 25, max 100). Response meta carries nextCursor and hasMore. Meaningful for /control/tenants and /tenant/me/agencies only - both have small N in V1 but the contract is set now to avoid V2 breakage.

3.3 Compact response shapes

These endpoints are operator-side (NFR-01) so the < 200 KB customer budget does not apply. Even so, list responses elide detail fields and require a follow-up GET on the resource for the full payload - this keeps list TTFB low under degraded 4G (NFR-02 ≤ 5 s on fallback).

// GET /api/control/tenants - list shape
{
  "data": [
    {
      "id": "01HZ...",
      "slug": "cabinet-akwaba",
      "legalName": "Cabinet Akwaba Assurances",
      "countryCode": "CI",
      "plan": "GROWTH",
      "status": "ACTIVE",
      "byoDb": false,
      "activatedAt": "2026-04-12T08:30:00Z"
    }
  ],
  "meta": { "nextCursor": "eyJ...", "hasMore": true }
}

// GET /api/control/tenants/{id} - detail shape (full)
// - includes address, primary_contact, last_provisioning_error, suspension_reason_*, etc.

3.4 Validation + error contract

  • Bean Validation on every DTO.
  • Field-level errors return HTTP 400 with errors: [{ field, code, messageFr }].
  • Tenant-isolation failure → HTTP 403 (per api-guidelines.md: NEVER 404, to avoid leaking existence).
  • Business-rule violation (TR-007 last-active-agency, TR-009 setting bounds) → HTTP 422 with translated French message.
  • All state-changing endpoints carry CSRF mitigation via the operator console's standard double-submit cookie, and require Idempotency-Key for POST /provision (only) - see §5.

4. Multi-Tenant Strategy

4.1 Tenant resolution

Caller Resolver
Platform admin Keycloak JWT carries realm role papillon-platform-admin AND no tenant_id claim. The OperatorTenantResolutionFilter recognises the role and does not set TenantContext; instead it sets TenantContext.platformAdmin() - a sentinel. Repositories that accept this sentinel write a PLATFORM_ADMIN_QUERY audit entry.
Tenant admin / operator Keycloak JWT carries tenant_id, agency_id?, country_code claims. Filter sets TenantContext.
Customer (signed-link) Out of scope for TENANT - handled by AUTH (SignedLinkResolutionFilter).

4.2 Cross-DB profile isolation

TenantAwareDataSource (blueprint §3.4) selects: - SharedDbDataSource when tenant.byo_db = false. - ByoDbDataSource when tenant.byo_db = true. Pool cached per-tenant in a ConcurrentHashMap, key = tenantId.

The only place that knows about the byo_db flag is TenantOnboardingService (during provisioning) and TenantDataSourceRouter (during request handling). All downstream code only sees getConnection(tenantId).

// Pseudocode for the routing decision
public class TenantDataSourceRouter implements TenantAwareDataSource {
    private final SharedDbDataSource shared;
    private final ByoDbDataSource    byo;
    private final TenantConfigCache  cache; // Redis-backed, TTL 5 min, invalidated on TenantSettingsChanged

    public Connection getConnection(String tenantId) {
        return cache.byoDb(tenantId) ? byo.getConnection(tenantId) : shared.getConnection(tenantId);
    }
}

4.3 Platform-DB access

The tenant and country_profile tables are accessed through @Qualifier("platformJdbcClient") JdbcClient per multi-datasource-patterns.md §"Pattern 1". Never via CrudRepository - Spring Data JDBC's @Primary routing would silently send the query to the tenant pool.

4.4 Cross-tenant "list all" safety

GET /api/control/tenants is the only endpoint in the entire platform that legitimately reads across tenants. Safety controls:

  1. Keycloak realm role check at the controller layer (@PreAuthorize("hasRole('papillon-platform-admin')")).
  2. The query goes through PlatformAdminTenantRepository (a separately-named, separately-tested repository) so a code search for "cross-tenant" lights up exactly one class.
  3. Every call writes a PLATFORM_ADMIN_QUERY audit entry with the SQL identifier.
  4. The repository never accepts user-supplied SQL fragments - all filters are typed enum / cursor params.

4.5 V2 path

Schema-per-tenant in V2 changes only TenantDataSourceRouter and adds a SchemaPerTenantDataSource impl. No tenant-scoped table changes. This is verified by a placeholder test (SchemaPerTenantPlaceholderTest) that asserts the routing interface signature and the lack of tenant_id-coupled DDL in tenant-scoped migrations.


5. Connectivity & Resumability

TENANT is operator-side (per PRD NFR-01, C.4). The customer-side V1 PRD §30 rules (low-bandwidth, signed-link, 200 KB budget) do not apply. There are no signed customer links issued by this module.

What still applies:

  • Idempotency on POST /provision (AC-02.2). Idempotency key = tenant.id itself; the provisioning state is held in tenant_provisioning_run. A retry on a step already in DONE is a no-op. See §6.1 sequence.
  • Compliance-doc upload tolerance: simple multipart, max 25 MB, content-type sniffed, MIME validated. PRD C.4 mentions chunked 5 MB; for V1 we adopt simple multipart with server-side resume via 416 Range responses - chunking on a 25 MB cap is over-engineering; if pilot ops show drops we revisit.
  • No "hors connexion" indicator on the tenant-admin UI (operator desktop). Failed POSTs surface a French toast with retry.

6. Sequence Diagrams

6.1 Tenant onboarding - happy path + retry

sequenceDiagram
    autonumber
    participant Admin as Platform Admin
    participant API as TenantController
    participant Svc as TenantOnboardingService
    participant DB as Platform DB
    participant Run as ProvisioningRunRepo
    participant Country as CountryProfileService
    participant Byo as ByoDbProvisioner
    participant KC as Keycloak (via AUTH)
    participant Bus as Spring Events

    Admin->>API: POST /control/tenants (form)
    API->>Svc: onboard(request)
    Svc->>DB: INSERT tenant (status=PENDING)
    Svc-->>API: 201 {tenantId}

    Admin->>API: POST /control/tenants/{id}/provision (Idempotency-Key=tenantId)
    API->>Svc: provision(tenantId)
    Svc->>Run: upsert run rows {CP_BIND, BYO_DB, KEYCLOAK, DEFAULT_AGENCY, EVENT_EMIT}=PENDING

    Svc->>Country: lookup(country_code)
    alt country profile missing
        Country-->>Svc: not found
        Svc->>Run: CP_BIND=FAILED
        Svc->>DB: UPDATE last_provisioning_error
        Svc-->>API: 422 {step:CP_BIND, error}
    else found
        Svc->>Run: CP_BIND=DONE
    end

    alt byo_db=true
        Svc->>Byo: testConnection + flyway migrate + smoke
        alt step fails
            Byo-->>Svc: error
            Svc->>Run: BYO_DB=FAILED
            Svc-->>API: 422 {step:BYO_DB, error}
        else
            Svc->>Run: BYO_DB=DONE
        end
    else
        Svc->>Run: BYO_DB=DONE (skipped)
    end

    Svc->>KC: createClient + roles (idempotent - existsByClientId first)
    Svc->>Run: KEYCLOAK=DONE

    Svc->>DB: INSERT default agency if none
    Svc->>Run: DEFAULT_AGENCY=DONE

    Svc->>DB: UPDATE tenant SET status=ACTIVE, activated_at=now()
    Svc->>Bus: publish TenantOnboarded (AFTER_COMMIT)
    Svc->>Run: EVENT_EMIT=DONE
    Svc-->>API: 200 {status:ACTIVE}

    Note over Admin,Bus: Retry on partial failure → re-runs only PENDING/FAILED steps.

6.2 Suspend → propagation

sequenceDiagram
    autonumber
    participant Admin as Platform Admin
    participant API as TenantController
    participant Svc as TenantLifecycleService
    participant DB as Platform DB
    participant Bus as Spring Events
    participant AUTH
    participant CPP as customer-portal
    participant PAY
    participant OP

    Admin->>API: POST /suspend {reasonCode, reasonText}
    API->>Svc: suspend(...)
    Svc->>DB: UPDATE tenant SET status=SUSPENDED, suspended_at=now()
    Svc->>DB: INSERT audit_entry TENANT_SUSPENDED
    Svc->>Bus: publish TenantSuspended (AFTER_COMMIT)
    par
        Bus->>AUTH: revoke active sessions
    and
        Bus->>CPP: signed-link verifier returns 503 from now on
    and
        Bus->>PAY: refuse new initiations
    and
        Bus->>OP: pause scheduled campaigns
    end
    Svc-->>API: 200
    Note over Bus,OP: Eventual consistency target ≤ 30 s (NFR-04).

6.3 Soft-delete (daily job) + PII purge

sequenceDiagram
    autonumber
    participant Cron as @Scheduled 02:00 Africa/Abidjan
    participant Job as TenantLifecycleJob
    participant DB as Platform DB
    participant Bus as Spring Events
    participant Purge as TenantPurgeWorker
    participant TenantDB as Tenant DB
    participant MinIO

    Cron->>Job: run()
    Job->>DB: SELECT id FROM tenant WHERE status='SUSPENDED' AND suspended_at <= now()-90d
    loop for each
        Job->>DB: UPDATE tenant SET status=DELETED, deleted_at=now()
        Job->>Bus: publish TenantSoftDeleted (persisted in spring-modulith-events-jdbc)
    end

    Bus->>Purge: TenantSoftDeleted
    Purge->>TenantDB: DELETE FROM customer WHERE tenant_id=?
    Purge->>TenantDB: UPDATE contract SET assured_*=NULL WHERE tenant_id=?
    Purge->>MinIO: DELETE tenant/{id}/ingestion/**
    Purge->>MinIO: KEEP tenant/{id}/legal/** AND tenant/{id}/billing/**
    Purge->>DB: INSERT audit_entry TENANT_PURGED
    Note over Purge,MinIO: Retried with exponential backoff. PII-purge failure paged immediately.

6.4 Network-drop / partial failure during provisioning

sequenceDiagram
    autonumber
    participant Admin
    participant API
    participant Svc as TenantOnboardingService
    participant Run
    participant Byo

    Admin->>API: POST /provision (Idempotency-Key=tenantId)
    API->>Svc: provision(tenantId)
    Svc->>Run: load runs
    Svc->>Byo: BYO_DB step starts
    Byo--xSvc: socket reset (timeout 30 s)
    Svc->>Run: BYO_DB=FAILED last_error="connection timeout"
    Svc-->>API: 422 {step:BYO_DB}
    Admin->>API: POST /provision again (after fixing network)
    API->>Svc: provision(tenantId) - same Idempotency-Key
    Svc->>Run: load runs (CP_BIND=DONE, BYO_DB=FAILED)
    Svc->>Byo: re-run BYO_DB only
    Byo-->>Svc: ok
    Svc->>Run: BYO_DB=DONE
    Svc->>Run: continue from KEYCLOAK ...

7. Security

7.1 Auth + RBAC

  • Operator side = Keycloak. Realm papillon. Roles used by TENANT:
  • papillon-platform-admin - sole authority for /api/control/tenants/....
  • tenant-admin, tenant-operator, tenant-viewer - set per tenant via Keycloak group + JWT tenant_id claim.
  • Every endpoint annotated @PreAuthorize with the required role; integration test asserts 403 (not 404) on cross-role access.
  • The platform-admin sentinel context (§4.4) is set ONLY by the OperatorTenantResolutionFilter; no service code can elevate at runtime.

7.2 Encryption

Data At rest
BYO-DB credentials Envelope: KEK in env (PAPILLON_KEK_BASE64) or hosted KMS in prod; per-tenant DEK encrypted by KEK; ciphertext = version‖nonce‖tag‖ct packed in BYTEA. Plaintext NEVER logged, NEVER returned to UI, NEVER in audit text.
Compliance docs (MinIO) Server-side encryption (SSE-S3 equivalent); object keys are unguessable (tenant/{tenantId}/legal/{type}/v{n}-{nanoid}.pdf). Download via short-TTL pre-signed URL (≤ 5 min) bound to platform-admin session.
Logo (MinIO) Public-read OK (logos are intended for customer view via CP). SVG sanitized (strip script, external refs) before save.
Logs MDC carries tenant_id, agency_id, country_code (blueprint §7.5). NEVER byo_db_password, NEVER raw Authorization headers.

7.3 Audit logging (V1 PRD §18)

Every endpoint that mutates state writes an audit entry with: event_code, actor_id, actor_role, tenant_id, target_type, target_id, before (redacted), after (redacted), reason_code?, reason_text?, ip, occurred_at, correlation_id. Events covered by this module:

Event code Trigger
TENANT_CREATED POST /control/tenants
TENANT_PROVISIONED provision pipeline → ACTIVE
TENANT_IDENTITY_EDITED PATCH /control/tenants/{id}/identity (closes OQ-01 if validated)
TENANT_SUSPENDED POST /suspend
TENANT_REACTIVATED POST /reactivate
TENANT_SOFT_DELETED daily job
TENANT_PURGED purge worker
TENANT_BRANDING_EDITED PATCH /tenant/me/branding
TENANT_SETTINGS_EDITED PATCH /tenant/me/settings (closes OQ-02 retention overrides)
AGENCY_CREATED POST /tenant/me/agencies
AGENCY_EDITED PATCH /tenant/me/agencies/{id}
AGENCY_DISABLED POST /tenant/me/agencies/{id}/disable
AGENCY_REACTIVATED POST /tenant/me/agencies/{id}/reactivate
COMPLIANCE_DOC_UPLOADED POST /control/tenants/{id}/compliance-docs
PLATFORM_ADMIN_QUERY GET /control/tenants (cross-tenant read)

Audit writes are best-effort-immediate: NFR-09 - failure to write audit MUST NOT roll back the business action. Pattern: business action commits; audit is published as AFTER_COMMIT event consumed by AUDIT module via the spring-modulith-events-jdbc outbox.

7.4 Secret management

  • KEK in env; in production, sourced from Coolify's encrypted env (or Hetzner SOPS-encrypted file).
  • Keycloak admin client credentials in env, scoped to realm-management.
  • MinIO credentials in env, scoped to a tenant-admin service account with prefix-only access to papillon/tenant/.

8. Performance & Bandwidth Budget

Concern Budget / Strategy
Page load (operator) ≤ 2 s broadband / ≤ 5 s 4G fallback (NFR-02). React 19.2 with route-level code-split; admin bundles target ≤ 350 KB gzipped initial (operator budget, not customer).
List endpoints Cursor pagination, default 25 rows, list shape elides detail fields (§3.3).
Caching Redis-backed TenantConfigCache (TTL 5 min, invalidated on TenantSettingsChanged/TenantSuspended/TenantReactivated/TenantSoftDeleted). Caches: byoDb flag, countryCode, status, defaultChannel, renewalWindowDays.
Provisioning Synchronous from the admin's POV up to 30 s (NFR-03). Long BYO-DB Flyway runs report progress via the retry button; the orchestrator persists step state in tenant_provisioning_run so the call can be re-issued.
Daily lifecycle job Bounded by N(SUSPENDED ≥ 90d). Uses an indexed scan on (status, suspended_at). Each tenant transition runs in its own DB transaction.
DPA-expiry warning job Bounded by N(active DPA WHERE expires_at ≤ now()+30d). Indexed scan on tenant_compliance_doc(expires_at).
Compliance-doc upload 25 MB cap; single multipart; streamed to MinIO (no in-memory buffering of full file).
Asset strategy Logo cached at edge (Cloudflare free) with Cache-Control: public, max-age=86400. Logo replaced → object key changes → cache miss naturally.

9. Migration Strategy

9.1 Flyway layout

db/migration/
├── platform/                          ← applied to platform DB
│   ├── V1__country_profile.sql
│   ├── V2__tenant.sql                 ← incl. country_code, group_id (NULL), partial UK on ncc
│   ├── V3__tenant_provisioning_run.sql
│   └── R__country_profile_seed_ci.sql ← repeatable seed
└── tenant/                            ← applied to each tenant DB (shared OR BYO-DB)
    ├── V1__agency.sql
    ├── V2__tenant_settings.sql
    └── V3__tenant_compliance_doc.sql
  • The platform-Flyway runs once at app start.
  • The tenant-Flyway runs:
  • At startup against the shared tenant DB.
  • During BYO_DB provisioning step against the supplied connection (TR-002 step 2).
  • Every migration is forward-only in production but ships with a hand-written db/undo/U{version}__{description}.sql for the dev environment (per database-guidelines.md "Migrations").
  • Migrations are tested in CI against both V1 profiles (matrix: shared, byo-db) - see §13.

9.2 Reversibility of business-state migrations

A tenant.status rollback (e.g. accidentally promoted to DELETED) is performed via a reversal record, never a hard UPDATE: a tenant_lifecycle_audit row captures the prior state, the new state, the actor, and the reason. The platform admin can apply a manual reversal that goes through the lifecycle service (not raw SQL), preserving audit chain.


10. Integration Points

Integration Direction Component / Library Notes
Keycloak admin OUT Spring Boot starter keycloak-admin-client (managed transitively); thin wrapper interface KeycloakProvisioningPort so it can be mocked. Step 3 of provisioning. Idempotency: query clientId exists before create.
MinIO OUT io.minio:minio Java SDK behind ObjectStorePort. Compliance docs (private), tenant logos (public-read), eventual quittances (other modules).
FNE / e-invoicing (CI) OUT (V1) Resolved via country_profile.e_invoicing_provider = 'FNE_CI'. Out of TENANT scope - actual integration lives in BILL. TENANT only stores the ncc + address. TENANT must validate ncc + address completeness at creation (TR-001) so BILL never has to reject a tenant for FNE compliance.
WhatsApp Cloud API OUT Out of TENANT scope. TENANT exposes whatsapp_template_id setting; NOTIF consumes it. -
SMS OUT Out of TENANT scope. NOTIF only. -
Webhooks (any) - TENANT does not receive webhooks in V1. -
File ingestion (CSV/XLSX) - Out of TENANT scope (ING). -

11. Vertical Slice Decomposition

PO input - not for DEVELOPER. The DEVELOPER reads only the story file at docs/stories/v<VERSION>/<module>/<STORY-ID>.md. This section exists for the PRODUCT_OWNER to decompose into self-contained stories.

The slices below are ordered by dependency. Concurrency tier is annotated on each. Tier 0 is serial and must merge first; Tier 1+ slices are parallelizable after their declared dependencies merge.

# Slice Tier Tag Complexity Depends on Scope (1-line)
S0 Dev environment + test infra T0 [V1] M - Bootstrap backend/+frontend/, docker-compose.yml (Postgres x2, Keycloak, Redis, MinIO), Testcontainers harness.
S1 Tenant entity + platform schema + country_profile seed T1 [V1] M S0 Migration V1__country_profile, V2__tenant, seed CI; JdbcClient repo on platform DB; module skeleton.
S2 Tenant create (PENDING) - API + form T1 [V1] M S1 POST /control/tenants happy path + AC-01.1/01.2/01.3/01.4 validations; admin form UI.
S3 Provisioning pipeline (idempotent) T1 [V1] L S1, S2, AUTH-S? (Keycloak port can be mocked) POST /provision, tenant_provisioning_run, step orchestrator, retry semantics; emits TenantOnboarded. Split candidate: 5+ behaviours - propose sub-slices S3a (CP_BIND + DEFAULT_AGENCY + EVENT_EMIT, shared-DB only), S3b (BYO_DB step + Flyway runner + connection encryption), S3c (Keycloak port + idempotent client create).
S4 Compliance doc upload + versioning T1 [V1] M S0, S1 POST/GET /compliance-docs, MinIO put/list, version chain, DPA gating activation (AC-06.3).
S5 Suspend / reactivate + propagation events T2 [V1] M S3 TenantSuspended + TenantReactivated; UI buttons; lifecycle audit; ≤ 30 s NFR-04.
S6 Daily lifecycle job - 90-day soft-delete T2 [V1] M S5 @Scheduled job, TenantSoftDeleted, lifecycle audit. Purge worker is S6b.
S6b PII + ingestion-file purge worker T2 [V1] M S6 Listener on TenantSoftDeleted purges customer, anonymises contract, deletes ingestion MinIO objects.
S7 List + detail (GET /control/tenants) T2 [V1] S S1, S2 Cross-tenant list with cursor + PLATFORM_ADMIN_QUERY audit.
S8 Identity edit (PATCH) T2 [V1] S S2 Platform-admin only field-level edit + TENANT_IDENTITY_EDITED audit (closes OQ-01 if founder validates).
S9 Agency CRUD (tenant-admin self-service) T2 [V1] M S3 List/create/edit/disable/reactivate; TR-007 last-active enforcement; AgencyDisabled.
S10 Tenant settings (bounded) + branding T2 [V1] M S3 PATCH /tenant/me/branding + /settings; bounds (TR-008/TR-009); TenantSettingsChanged; logo SVG sanitisation.
S11 DPA expiry daily warning + admin banner T3 [V1] S S4 Daily job; admin dashboard banner; no auto-suspension.

Notes: - S0 is mandatory and serial. The module introduces Postgres (x2 logical DBs), Keycloak (realm + client), Redis, MinIO. Without S0 nothing else compiles. - S3 is the most complex; the split candidate above is the recommended sub-slicing for PO. - S6 and S6b are deliberately split: PII purge has different blast radius from lifecycle transition and warrants its own review.


12. Technical Risks

# Risk Likelihood Impact Mitigation
R1 Cross-tenant query leak via accidental CrudRepository on platform table Medium High Code-search guard + module-test that asserts no CrudRepository<Tenant, ?> exists. REVIEWER checklist updated.
R2 BYO-DB credentials leak to logs / errors Medium High Single chokepoint (Encryptor + TenantConfigRepository). Logback MaskingPatternConverter redacts JDBC URLs that contain password=. Integration test attempts to log + asserts.
R3 Provisioning partial failure leaves tenant in PENDING forever Medium Medium tenant_provisioning_run step state + retry button (AC-02.2) + Prometheus alert when N tenants stay PENDING > 24 h.
R4 Keycloak unavailable during provisioning Medium Medium Step is idempotent. Tenant stays PENDING with KEYCLOAK_UNREACHABLE last_error. Health check warns on Keycloak < 95 % uptime.
R5 Wrong country profile selected for V2 SN/BJ tenant due to default 'CI' Low High DB column country_code has NO default; the application default is enforced in V1 onboarding service AND the column is NOT NULL. Adding SN/BJ in V2 requires only seeding new rows.
R6 Race: tenant suspended while a long-running ingestion is mid-flight Low Medium Listener invalidates cache; long-running jobs check TenantContext.status between batches. No hard-kill; in-flight job finishes its current batch and refuses next.
R7 90-day grace clock skewed by manual DB writes Low Medium Lifecycle transitions are gated by TenantLifecycleService (no raw SQL); CI test forbids UPDATE tenant SET status outside of the service.
R8 Compliance doc deletion via direct MinIO access Low High Bucket lifecycle policy forbids DELETE on tenant/*/legal/** and tenant/*/billing/**. Service account has no s3:DeleteObject for those prefixes.
R9 Audit write fails silently and a regulator-relevant event is lost Medium High Audit goes through spring-modulith-events-jdbc outbox; failed publication triggers a Prometheus alert. Audit DB is the LAST to be considered "down" - outbox queue grows.
R10 OQ-01/OQ-02 audit gap on identity / settings changes Medium Medium S8 + S10 already include the audit entries; OQ-01/OQ-02 disposition is "yes, log" pre-emptively.

13. Test Infrastructure

13.1 Containers & doubles

Dependency In tests
Postgres (platform DB) PostgreSQLContainer:16 reused across the suite via @Testcontainers + Spring's @ServiceConnection.
Postgres (tenant DB) A second PostgreSQLContainer for the shared tenant DB; tests requiring BYO-DB spawn an extra ephemeral container per test class.
Keycloak KeycloakContainer:26 from dasniko/testcontainers-keycloak; pre-seeded realm export papillon-test.json. For unit tests, KeycloakProvisioningPort is mocked.
Redis GenericContainer("redis:7-alpine").
MinIO MinIOContainer (or the official testcontainers module) with a pre-created papillon bucket.
Spring Application Events Real publisher; tests assert via @RecordApplicationEvents from Spring Test.

13.2 Sample tests

13.2.1 Tenant isolation (must pass for every tenant-scoped query)

@SpringBootTest
@Testcontainers
class AgencyTenantIsolationTest {

    @Autowired AgencyService svc;

    @Test
    void agencyOfTenantA_isInvisibleToTenantB() {
        var tA = fixtures.tenant("CI", "akwaba");
        var tB = fixtures.tenant("CI", "alou");
        var aA = runAs(tA, () -> svc.create(new AgencyCreate("BKO", "Abidjan-Cocody", "Abidjan")));
        runAs(tB, () -> {
            assertThat(svc.list().data()).isEmpty();
            assertThatThrownBy(() -> svc.get(aA.id())).isInstanceOf(ForbiddenException.class);
        });
    }
}

13.2.2 Idempotent provisioning retry

@SpringBootTest
@Testcontainers
class ProvisioningRetryTest {

    @Test
    void retryRunsOnlyFailedSteps() {
        var t = fixtures.tenant("CI", "alou", byoDb = true);
        // Inject a faulty BYO-DB connection so the BYO_DB step fails
        byoDbMock.failNext();

        assertThat(svc.provision(t.id())).isFailedAt("BYO_DB");
        assertThat(runs(t.id(), "CP_BIND")).isDone();
        assertThat(runs(t.id(), "BYO_DB")).isFailed();

        byoDbMock.recover();
        assertThat(svc.provision(t.id())).isSuccess();
        // CP_BIND not re-executed
        assertThat(runs(t.id(), "CP_BIND").attemptCount()).isEqualTo(1);
        assertThat(runs(t.id(), "BYO_DB").attemptCount()).isEqualTo(2);
    }
}

13.2.3 Network-drop on provisioning (low-bandwidth analogue)

This module is operator-side, so the customer-flow §30 rules don't apply. The closest equivalent is "admin's network drops between POSTs":

@Test
void adminPostRetryAfterDrop_isObservablyIdempotent() {
    var t = create();
    var idempotencyKey = t.id();
    // Simulate first POST timing out - server processes, response lost
    server.processedButResponseDropped(() -> svc.provision(t.id(), idempotencyKey));

    // Admin retries - same idempotency key
    var second = svc.provision(t.id(), idempotencyKey);
    assertThat(second.runs()).hasSize(5).allMatch(r -> r.status() == DONE);
    assertThat(events.recorded(TenantOnboarded.class).count()).isEqualTo(1);
}

13.2.4 Cross-tenant leak guard (Modulith verification)

@Test
void noCrudRepositoryOnTenantTable() {
    var modules = ApplicationModules.of(PapillonApplication.class);
    modules.verify(); // package boundaries
    var classes = ClassPathScanningCandidateComponentProvider.findInterfacesExtending(CrudRepository.class);
    assertThat(classes).noneSatisfy(c -> assertThat(c.getName()).contains("Tenant"));
}

14. Local Dev Environment

This is the first technical session - docker-compose.yml does not yet exist. S0 (Tier 0) creates the file below; the values are illustrative for V1 dev and overridden by .env.

14.1 docker-compose.yml (initial)

# Papillon Collection Solution - V1 dev environment
services:
  postgres-platform:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: papillon_platform
      POSTGRES_USER: papillon
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-papillon}
    ports: ["5433:5432"]
    volumes: [pg_platform:/var/lib/postgresql/data]
    healthcheck: { test: ["CMD-SHELL", "pg_isready -U papillon"], interval: 5s }

  postgres-tenant:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: papillon_tenant
      POSTGRES_USER: papillon
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-papillon}
    ports: ["5434:5432"]
    volumes: [pg_tenant:/var/lib/postgresql/data]
    healthcheck: { test: ["CMD-SHELL", "pg_isready -U papillon"], interval: 5s }

  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    command: start-dev --import-realm
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin}
    ports: ["8081:8080"]
    volumes: ["./infra/keycloak:/opt/keycloak/data/import:ro"]

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  minio:
    image: minio/minio:latest
    command: server /data --console-address :9001
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio12345}
    ports: ["9000:9000", "9001:9001"]
    volumes: [minio_data:/data]

volumes:
  pg_platform:
  pg_tenant:
  minio_data:

14.2 Environment variables

# .env.example
PAPILLON_KEK_BASE64=<32 random bytes b64>
PLATFORM_DB_URL=jdbc:postgresql://localhost:5433/papillon_platform
TENANT_DB_URL=jdbc:postgresql://localhost:5434/papillon_tenant
DB_USER=papillon
DB_PASSWORD=papillon
KEYCLOAK_BASE_URL=http://localhost:8081
KEYCLOAK_REALM=papillon
KEYCLOAK_ADMIN_CLIENT_ID=papillon-admin
KEYCLOAK_ADMIN_CLIENT_SECRET=changeme
REDIS_URL=redis://localhost:6379
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minio
MINIO_SECRET_KEY=minio12345
MINIO_BUCKET=papillon

14.3 Smoke verification

# Start the stack
docker compose up -d

# Run the app
./gradlew :backend:bootRun

# Smoke: create a CI tenant, provision it, verify ACTIVE
curl -sS -X POST http://localhost:8080/api/control/tenants \
  -H "Authorization: Bearer $PLATFORM_ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d @examples/tenant-akwaba.json

curl -sS -X POST http://localhost:8080/api/control/tenants/$TENANT_ID/provision \
  -H "Authorization: Bearer $PLATFORM_ADMIN_JWT" \
  -H "Idempotency-Key: $TENANT_ID"

curl -sS http://localhost:8080/api/control/tenants/$TENANT_ID \
  -H "Authorization: Bearer $PLATFORM_ADMIN_JWT" | jq '.data.status'   # expect "ACTIVE"

15. Frontend Design System (reference)

The design system is already locked in docs/standards/design-system.md (custom CSS Modules + design tokens, no external component library - chosen for low-bandwidth, dual-brand support, and customization depth). TENANT does not re-evaluate it. The TENANT admin UI consumes the existing tokens and component inventory; new components introduced by TENANT (e.g. provisioning step list, compliance-doc version chain) are added back to the design-system inventory.


16. Open Questions (carried + new)

Carried from PRD §A.6: OQ-01..OQ-06.

New, raised by this architecture: - OQ-A1: Should tenant_provisioning_run survive past status=DONE for forensic purposes, or be archived to audit and pruned at 90 days? Proposal: keep indefinitely (small volume, useful for support). - OQ-A2: For BYO-DB, do we ship Flyway-managed migrations to the tenant's DBA on contract signature, or apply them blind? Proposal: apply blind via Flyway with a contractual clause ("Papillon manages the schema in your DB"). - OQ-A3: Should papillon-platform-admin be a Keycloak realm role (current proposal) or live in a separate papillon-admin realm? Proposal: realm role for V1 - separate realm only if a third-party platform-admin federation appears.


17. Summary

tenant-admin is the front door of every other module. V1 keeps it operator-driven, CI-only, with a tight lifecycle (PENDING → ACTIVE → SUSPENDED → DELETED) and full forward-compat for V2 (group, foreign subsidiaries, schema-per-tenant). The hard parts are: (1) a clean TenantAwareDataSource that does not leak the BYO-DB choice past one router class, (2) idempotent provisioning whose step state survives partial failure, (3) audit fidelity good enough to defend the CIMA + ARTCI + FNE positioning at any point in time. The slices in §11 deliver these one thin end-to-end at a time, with S0 setting up the infra all later slices need.


End of architecture - tenant-admin (TENANT) - V1 Next stage: DESIGNER (docs/design/tenant-admin-design.md) for the operator-side UX, then PRODUCT_OWNER decomposes §11 into stories.