Aller au contenu

Architecture - audit-log (AUDIT) module - V1

Module prefix: AUDIT . Plane: Control . Version scope: [V1] (with V0 deltas in section 0) . Country scope V1: CI only Author: ARCHITECT . Inputs: docs/prd/audit-log-prd.md, docs/prd/prd-v1.md (sections 16.5, 17, 18, 27, 30), docs/prd/prd-v0.md (section 4 AUDIT line + section 5.6 outbox anchor + section 8 mapping), docs/architecture/platform-saas-blueprint.md (section 3.5 + section 3.6 + section 5.1), docs/architecture/tenant-admin-arch.md (D8 envelope encryption pattern, section 7.2), docs/architecture/identity-arch.md, docs/architecture/PROGRESS.md. 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 AUDIT est livrée progressivement à travers les sous-versions V0 (v0.0.0 -> v0.1.0). Tout le contenu ci-dessous (section 1 à section 14) 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 section 4 (ligne AUDIT), section 5.6 (outbox D4 + D7) et section 8 (mapping stories).

0.1 Découpage par sous-version

Sous-version Livrable AUDIT vs cette architecture V1 Story IDs
v0.0.0 Schéma audit_log complet V1 (section B.5 du PRD) figé Day 1 sur la platform DB. Table audit_tenant_salt + bean PiiHasher (anchor envelope encryption D8) câblés Day 1. Outbox spring-modulith-events-jdbc consommé par AUDIT (anchors D4 + D7). Stubs d'émission câblés pour TOUTES les familles d'événements de section B.1, même si seules certaines familles émettent réellement à v0.0.0 (TENANT, AUTH, ING, NOTIF). Flyway Java callback AUDIT_SCHEMA_MIGRATED actif Day 1. Pas de query API, pas d'export, pas de console. AUDIT-001
v0.0.1 Émission réelle activée sur les chemins critiques paiement (PAY_INITIATED, PAY_CONFIRMED, PAY_FAILED, PAY_RECONCILED) et notification (NOTIF_SENDING, NOTIF_SENT, NOTIF_FAILURE, NOTIF_SUPPRESSED_OPT_OUT). Le PiiHasher est désormais utilisé en chemin chaud (NOTIF + CP). Pas d'API consommatrice nouvelle. AUDIT-002
v0.1.0 Stubs des compliance reports (AUDIT-US-04 + AUDIT-US-09) : endpoint POST /api/control/audit/exports posé, génération réelle minimale (CSV par tenant + plage de dates, streamé vers MinIO + URL signée), pas d'UI dédiée. Alerting santé d'ingest (AUDIT-US-09) en mode stub log-only (métriques Prometheus exposées, règle d'alerte non câblée). AUDIT-003

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

  • Schéma audit_log complet V1 dès v0.0.0. Toutes les colonnes de section B.5 du PRD (event_id UUIDv7, event_type, event_schema_version, business_committed_at, audit_persisted_at, tenant_id, acting_on_tenant_id, agency_id, country_code, producer_module, producer_outbox_id, actor_user_id, correlation_id, customer_id, contract_id, signed_link_id, result, client_ip, user_agent, payload). Les producteurs qui n'ont pas encore de valeur pour une colonne facultative laissent NULL. Aucune migration de colonne sur audit_log entre v0.0.0 et v0.1.0. Justification : cohérence avec le principe "schema columns Day 1" déjà verrouillé pour TENANT et AUTH ; éviter toute ALTER TABLE sur une table monotone croissante.
  • Outbox spring-modulith-events-jdbc câblé Day 1. Tout producteur d'audit publie un domain event dans la même transaction que son business write (anchor D4) ; un @ApplicationModuleListener côté AUDIT consomme depuis l'event_publication table et INSERT dans audit_log. Failure d'INSERT ne rollback PAS l'action métier (anchor D7, NFR-09 best-effort-immediate).
  • Pseudonymisation Day 1. Table audit_tenant_salt + bean PiiHasher exposés via une named-interface Modulith. Chaque tenant crée son salt à TenantOnboarded. Envelope encryption (DEK par tenant, wrappé par KEK env var) reprenant le pattern D8 de TENANT-arch section 7.2. Voir docs/kb/audit-salt-storage-options.md pour le pourquoi de cette option vs tenant_settings ou un secrets-manager externe.
  • DB-level append-only Day 1 (avance sur le banner du PRD qui dit "convention applicative en v0.0.0"). Le rôle Postgres papillon_app n'a JAMAIS UPDATE ni DELETE sur audit_log, dès v0.0.0. La V1 ajoute en plus un rôle papillon_audit_reader (SELECT only) consommé par les query APIs (story AUDIT-005 V1). Le rôle papillon_audit_admin (DDL + INSERT) n'est utilisé que par Flyway au deploy. Le code applicatif ne peut structurellement pas rewriter une ligne d'audit, même par bug.
  • Flyway Java callback AUDIT_SCHEMA_MIGRATED Day 1. Posté en afterMigrate, sous le rôle papillon_audit_admin, dans la même transaction que la migration. Atomique. Le runtime applicatif (rôle papillon_app) ne peut pas émettre cet event_type (FR-01 ferme la porte au boundary applicatif ; le rôle DB ferme la porte au niveau Postgres).
  • Tenant scoping strict sur tous les reads dès qu'une API consommatrice existe (V1). En V0, pas d'API consommatrice : la table est en écriture seule depuis l'application. Toute lecture future passera obligatoirement par le TenantContext + FR-04.

0.3 Hors V0 (renvoyé à V1 ou plus tard)

  • Query API filtrée + by-case lookup (AUDIT-US-02 + US-03) : entièrement reportées à V1. En V0 la table est en écriture seule depuis l'application ; les reads passent par des dumps SQL ops-time si besoin, audités comme AUDIT_QUERY_EXECUTED à la main.
  • Export JSONL + manifest + SHA-256 (AUDIT-US-04 complet) : V0.1.0 livre une version dégradée (CSV plat, pas de manifeste, pas de signature) ; la version V1 cible (JSONL + manifest.json + SHA-256 + intégrité vérifiable) attend V1.
  • Alerting actif AUDIT_INGEST_DEGRADED (AUDIT-US-09) : V0.1.0 expose les métriques Prometheus (audit_outbox_incomplete_count, audit_freshness_p95_seconds, audit_oldest_incomplete_age_seconds) ; les règles d'alerte Grafana + le routage on-call sont V1.
  • Pseudonymisation rigide + defensive scan au boundary AUDIT (FR-03 second alinéa) : la lib PiiHasher est exposée Day 1, donc les producteurs hashent dès v0.0.0 ; mais le scan défensif à l'INSERT (qui refuse de persister une string qui ressemble à un raw phone / IBAN / email) n'est pas câblé en V0. Risque : un producteur qui oublie de hasher peut faire passer du clair. Mitigation V0 : revue de code AUDIT-002 sur chaque producteur activé.
  • Self-audit AUDIT_QUERY_EXECUTED + AUDIT_EXPORT_COMPLETED (AUDIT-US-08) : émis dès qu'une API consommatrice existe (V1). En V0, l'export V0.1.0 émet déjà AUDIT_EXPORT_COMPLETED au minimum.
  • Console AUDIT opérateur / admin : entièrement V1. Aucun écran ni route React touchant AUDIT n'existe en V0.
  • Tamper-evidence cryptographique (hash chain, Merkle, WORM, MinIO object-lock) : V2 (PRD AC-05.4 honest scope).
  • Purge automatique 10 ans (AC-07.4) : V2.
  • Rotation des salts + crypto-erasure (PRD A.6 OQ-02) : V2.
  • Résidence des audit rows côté BYO-DB tenant (PRD A.6 NFR-08 + TV-05) : V2. En V0 + V1, audit_log vit sur la platform DB pour TOUS les tenants, y compris BYO. Documenté dans le contrat d'onboarding BYO côté TENANT (story TENANT-003).

Aucun statut INCERTAIN - avocat requis ou PRÉLIMINAIRE du memo F5 n'est levé par le découpage V0. Les notes [LEGAL-REVIEW] sur les durées applicatives (memo F5 PRÉLIMINAIRE) restent en vigueur partout où une durée est figée.


1. System Context

1.1 What AUDIT is

AUDIT is the immutable evidence trail of every regulator-relevant action that the platform performs on behalf of a tenant. It is the artifact that satisfies V1 PRD section 16.5 (proof obligation under CIMA Reg. 01-24 + CI fiscal retention + ARTCI doctrine). It is a Control Plane module (per platform-saas-blueprint.md section 5.1) consumed by every other module as a guaranteed side effect of their business writes.

In V1 it has no UI, no customer surface, and no operator surface. The only readers are platform admins, via internal HTTP endpoints + JSONL exports. The PRD intentionally keeps the V1 surface narrow.

1.2 Position in the platform

flowchart LR
    subgraph CP_PLANE["CONTROL PLANE"]
        TENANT["tenant-admin"]
        AUTH["identity / AUTH"]
        BILL["billing / BILL (no V1 producer link)"]
        AUDIT["audit-log / AUDIT<br/>· audit_log table<br/>· audit_tenant_salt<br/>· audit_export<br/>· PiiHasher bean<br/>· outbox consumer<br/>· Flyway callback"]
        NOTIF["notifications / NOTIF"]
    end
    subgraph APP_PLANE["APPLICATION PLANE"]
        ING["ingestion"]
        CPP["customer-portal"]
        OPC["operator-console"]
        PAY["payment"]
    end
    subgraph EXT["External infrastructure"]
        OUTBOX[("event_publication<br/>(spring-modulith-events-jdbc)")]
        MINIO[("MinIO<br/>(audit-exports bucket)")]
        PROM[("Prometheus<br/>(metrics)")]
    end

    TENANT -- "outbox event" --> OUTBOX
    AUTH   -- "outbox event" --> OUTBOX
    NOTIF  -- "outbox event" --> OUTBOX
    ING    -- "outbox event" --> OUTBOX
    CPP    -- "outbox event" --> OUTBOX
    OPC    -- "outbox event" --> OUTBOX
    PAY    -- "outbox event" --> OUTBOX

    OUTBOX --> AUDIT
    AUDIT -- "INSERT" --> AUDIT_DB[("audit_log<br/>on platform DB")]
    AUDIT -- "export bundles" --> MINIO
    AUDIT -- "metrics (tenant_id tag)" --> PROM

    NOTIF -. uses .-> AUDIT
    CPP   -. uses .-> AUDIT
    PAY   -. uses .-> AUDIT
    AUTH  -. uses .-> AUDIT
    ING   -. uses .-> AUDIT
    OPC   -. uses .-> AUDIT
    TENANT -. uses .-> AUDIT

    classDef ctrl fill:#e6f0ff,stroke:#3366aa
    classDef app  fill:#fff5e6,stroke:#aa7733
    classDef ext  fill:#eaeaea,stroke:#666
    class TENANT,AUTH,BILL,AUDIT,NOTIF ctrl
    class ING,CPP,OPC,PAY app
    class OUTBOX,MINIO,PROM,AUDIT_DB ext

Two arrows per producer: - A solid arrow representing the outbox-mediated audit write (every producer publishes a domain event in the same transaction as its business write; AUDIT's listener drains the outbox and INSERTs into audit_log). - A dashed "uses" arrow representing the in-process Spring bean dependency on PiiHasher (the hashing primitive owned by AUDIT and consumed by any producer that may emit PII).

1.3 Cross-module dependency direction

  • AUDIT never depends on any other module for runtime behavior. It is downstream of everything.
  • AUDIT does depend on TENANT at provisioning time: when a TenantOnboarded fires, AUDIT must create the audit_tenant_salt row for that tenant. This is the only inbound coupling.
  • Every other module depends on AUDIT for:
  • the PiiHasher bean (compile-time dependency, in-process call),
  • the catalog of event_type constants exposed as Java enums (compile-time dependency),
  • the outbox-consumer guarantee (runtime, transparent via spring-modulith-events-jdbc).

This direction respects the blueprint rule (section 5.3): "Control-plane modules must not depend on app-plane modules. Verified by ApplicationModules.verify()."

1.4 What AUDIT exposes

Surface Audience When
PiiHasher bean (named-interface) every producer module v0.0.0 Day 1
AuditEventType enum + catalog API every producer module v0.0.0 Day 1
outbox listener (@ApplicationModuleListener) implicit (Modulith) v0.0.0 Day 1
Flyway AuditSchemaMigratedCallback deploy pipeline v0.0.0 Day 1
Prometheus metrics (audit_*) ops / Grafana v0.1.0
POST /api/control/audit/exports (degraded CSV) platform admin v0.1.0
POST /api/control/audit/query platform admin V1
GET /api/control/audit/cases/{key} platform admin V1
POST /api/control/audit/exports (JSONL + manifest) platform admin V1
GET /api/control/audit/exports/{id} platform admin V1

API namespaces are reserved in docs/architecture/PROGRESS.md under /api/control/audit/... (D5).

1.5 Locked architectural decisions (this session)

ID Decision Locked in
AUDIT-ADR-01 Audit data lives on the platform DB regardless of tenant db_profile. BYO-DB tenants still have their audit rows centralized. V2 may revisit (TV-05). section 4
AUDIT-ADR-02 Producer-to-AUDIT path uses spring-modulith-events-jdbc outbox (anchors D4 + D7). Audit failure does NOT roll back the business action. section 6
AUDIT-ADR-03 Audit schema = full V1 (PRD section B.5) frozen Day 1 (v0.0.0). No ALTER TABLE audit_log between v0.0.0 and v0.1.0. section 0.2
AUDIT-ADR-04 Per-tenant pseudonymization salts in AUDIT-owned table audit_tenant_salt, envelope-encrypted (D8 pattern). See docs/kb/audit-salt-storage-options.md. section 7.3
AUDIT-ADR-05 Export bundles materialized in MinIO bucket audit-exports. Streaming digest, presigned URL (TTL 15 min) returned to caller. section 3 + 7
AUDIT-ADR-06 AUDIT_SCHEMA_MIGRATED emitted via Flyway Java callback running as papillon_audit_admin role, atomic with the migration transaction. section 9
AUDIT-ADR-07 Three Postgres roles: papillon_app (INSERT+SELECT on audit_log, no UPDATE/DELETE), papillon_audit_reader (SELECT only, V1), papillon_audit_admin (DDL+all, deploy only). section 7.2
AUDIT-ADR-08 correlation_id is generated by OP at OP_CAMPAIGN_VALIDATED and propagated downstream through AUTH -> NOTIF -> CP -> PAY. Operator/admin standalone events do not carry one. (Resolves PRD OQ-05; pending cross-module confirmation by OP + NOTIF + CP + PAY architects -> OQ-X3 in PROGRESS.md.) section 2

2. Data Model

2.1 Entity overview

AUDIT owns three tables on the platform DB, plus consumes event_publication from spring-modulith-events-jdbc (which is itself a platform-DB table managed by the library, not by AUDIT).

erDiagram
    audit_log {
        UUID event_id PK "UUIDv7 - time-ordered for index locality"
        TEXT event_type "FR-01: one of catalog (section B.1 of PRD)"
        SMALLINT event_schema_version "starts at 1, per event_type"
        TIMESTAMPTZ business_committed_at "producer commit time (source of truth)"
        TIMESTAMPTZ audit_persisted_at "AUDIT INSERT time"
        UUID tenant_id "NOT NULL except AUDIT_SCHEMA_MIGRATED"
        UUID acting_on_tenant_id "platform admin acting on tenant"
        UUID agency_id "optional per FR section B.3"
        CHAR_2 country_code "snapshotted from tenant at event time"
        TEXT producer_module "TENANT|AUTH|ING|OP|CP|PAY|NOTIF|AUDIT|deploy"
        TEXT producer_outbox_id "FR-06 idempotence key"
        TEXT actor_user_id "Keycloak sub or NULL for system actors"
        UUID correlation_id "renewal-cycle correlation"
        UUID customer_id "optional"
        UUID contract_id "optional"
        UUID signed_link_id "optional"
        TEXT result "SUCCESS|FAILURE|INFO"
        INET client_ip "optional"
        TEXT user_agent "optional, truncated 500 chars"
        JSONB payload "PII pseudonymized via PiiHasher"
    }
    audit_tenant_salt {
        UUID tenant_id PK "1:1 with tenant"
        BYTEA salt_ciphertext "encrypted with tenant DEK"
        BYTEA wrapped_dek "DEK encrypted with KEK"
        SMALLINT kek_version "KEK rotation discriminator"
        TIMESTAMPTZ created_at
        TIMESTAMPTZ rotated_at "NULL if never rotated"
    }
    audit_export {
        UUID export_id PK
        UUID requested_by "platform admin Keycloak sub"
        UUID tenant_id "scope tenant"
        TIMESTAMPTZ date_range_start
        TIMESTAMPTZ date_range_end
        TEXT scope_event_types "comma-separated, optional"
        UUID scope_correlation_id "optional"
        TEXT status "PENDING|RUNNING|COMPLETED|FAILED"
        BIGINT event_count "populated on COMPLETED"
        TEXT jsonl_object_key "MinIO key"
        TEXT manifest_object_key "MinIO key"
        TEXT jsonl_sha256 "hex digest"
        TEXT error_message "populated on FAILED"
        TIMESTAMPTZ requested_at
        TIMESTAMPTZ started_at
        TIMESTAMPTZ completed_at
    }
    event_publication {
        UUID id PK "owned by spring-modulith-events-jdbc"
        TEXT listener_id
        TEXT event_type
        TEXT serialized_event
        TIMESTAMPTZ publication_date
        TIMESTAMPTZ completion_date "NULL while incomplete"
    }

    audit_tenant_salt ||--o{ audit_log : "salts the PII hashed into payload"
    audit_export }o--|| audit_log : "export bundles a slice of rows"
    event_publication ||--o{ audit_log : "drained by AUDIT listener -> INSERT"

Note: event_publication is shown for clarity but is not owned by AUDIT. It is the spring-modulith-events-jdbc outbox table. AUDIT depends on it as a runtime infrastructure dependency, the same way it depends on Postgres.

2.2 audit_log - canonical column reference

The schema mirrors PRD section B.5 exactly. All columns NOT NULL unless marked optional in PRD section B.3.

CREATE TABLE audit_log (
    event_id              UUID PRIMARY KEY,
    event_type            TEXT NOT NULL,
    event_schema_version  SMALLINT NOT NULL,
    business_committed_at TIMESTAMPTZ NOT NULL,
    audit_persisted_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    tenant_id             UUID,            -- NULL only for AUDIT_SCHEMA_MIGRATED
    acting_on_tenant_id   UUID,            -- platform admin acting on tenant
    agency_id             UUID,
    country_code          CHAR(2),
    producer_module       TEXT NOT NULL,
    producer_outbox_id    TEXT,
    actor_user_id         TEXT,
    correlation_id        UUID,
    customer_id           UUID,
    contract_id           UUID,
    signed_link_id        UUID,
    result                TEXT NOT NULL CHECK (result IN ('SUCCESS','FAILURE','INFO')),
    client_ip             INET,
    user_agent            TEXT,
    payload               JSONB NOT NULL DEFAULT '{}'::jsonb,
    CHECK (
        event_type <> 'AUDIT_SCHEMA_MIGRATED' OR tenant_id IS NULL
    ),
    CHECK (
        event_type = 'AUDIT_SCHEMA_MIGRATED' OR tenant_id IS NOT NULL
    )
);

Constraints intentionally simple in V1: - Catalog gate (FR-01) enforced at the application boundary via the AuditEventType enum, not as a Postgres CHECK (adding event_types should not require a DB migration once V1 freezes). - Mandatory-field gate (FR-02, decision table section B.3) enforced at the application boundary by the AUDIT write API, which validates the event before passing it to the JDBC layer. - producer_outbox_id uniqueness enforced via the partial unique index below, not as a CHECK.

2.3 Indexing strategy

-- Primary query path (AUDIT-US-02 filter + AUDIT-US-04 export scan):
CREATE INDEX audit_log_tenant_ts_idx
    ON audit_log (tenant_id, business_committed_at DESC);

-- Operator/admin activity queries:
CREATE INDEX audit_log_actor_ts_idx
    ON audit_log (actor_user_id, business_committed_at DESC)
    WHERE actor_user_id IS NOT NULL;

-- By-case lookup (AUDIT-US-03):
CREATE INDEX audit_log_correlation_idx
    ON audit_log (correlation_id) WHERE correlation_id IS NOT NULL;
CREATE INDEX audit_log_customer_idx
    ON audit_log (customer_id)    WHERE customer_id    IS NOT NULL;
CREATE INDEX audit_log_contract_idx
    ON audit_log (contract_id)    WHERE contract_id    IS NOT NULL;
CREATE INDEX audit_log_signed_link_idx
    ON audit_log (signed_link_id) WHERE signed_link_id IS NOT NULL;

-- FR-06 idempotence (replay safety on outbox redelivery):
CREATE UNIQUE INDEX audit_log_producer_dedup_uk
    ON audit_log (producer_module, producer_outbox_id)
    WHERE producer_outbox_id IS NOT NULL;

-- Acting-on-tenant FR-04 path:
CREATE INDEX audit_log_acting_tenant_idx
    ON audit_log (acting_on_tenant_id, business_committed_at DESC)
    WHERE acting_on_tenant_id IS NOT NULL;

Rationale: - tenant_id, business_committed_at DESC is the canonical hot path. Every AUDIT-US-02 filter starts from a tenant + a date window. - Partial indexes on optional case keys keep the index small (most audit rows do not carry signed_link_id, for example). - The producer_outbox_id unique index is the structural defense against duplicate audit rows when spring-modulith-events-jdbc redelivers an event after a partial failure (FR-06).

2.4 audit_tenant_salt

CREATE TABLE audit_tenant_salt (
    tenant_id        UUID PRIMARY KEY REFERENCES tenant(id) ON DELETE RESTRICT,
    salt_ciphertext  BYTEA NOT NULL,
    wrapped_dek      BYTEA NOT NULL,
    kek_version      SMALLINT NOT NULL DEFAULT 1,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    rotated_at       TIMESTAMPTZ
);
  • ON DELETE RESTRICT prevents tenant hard-delete (which is already forbidden by CIMA + ARTCI retention; this is belt-and-suspenders).
  • kek_version is the discriminator for future V2 KEK rotation. v0.0.0 ships with kek_version = 1, KEK in env var AUDIT_KEK.
  • The salt is generated at TenantOnboarded reception (32 bytes from SecureRandom).

2.5 audit_export

CREATE TABLE audit_export (
    export_id              UUID PRIMARY KEY,
    requested_by           TEXT NOT NULL,        -- Keycloak sub
    tenant_id              UUID NOT NULL REFERENCES tenant(id) ON DELETE RESTRICT,
    date_range_start       TIMESTAMPTZ NOT NULL,
    date_range_end         TIMESTAMPTZ NOT NULL,
    scope_event_types      TEXT,                  -- comma-separated subset of catalog
    scope_correlation_id   UUID,
    status                 TEXT NOT NULL DEFAULT 'PENDING'
                              CHECK (status IN ('PENDING','RUNNING','COMPLETED','FAILED')),
    event_count            BIGINT,
    jsonl_object_key       TEXT,
    manifest_object_key    TEXT,
    jsonl_sha256           TEXT,
    error_message          TEXT,
    requested_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
    started_at             TIMESTAMPTZ,
    completed_at           TIMESTAMPTZ
);

CREATE INDEX audit_export_tenant_requested_idx
    ON audit_export (tenant_id, requested_at DESC);

The audit_export row is itself an audit_log event when status flips to COMPLETED (AUDIT_EXPORT_COMPLETED, AC-04.5 + AC-08.2). The audit_export row stays for operational tracking and re-download; the audit-log event is the regulator-facing record.

2.6 Multi-tenant column conventions

Every row in audit_log carries a tenant_id (NULL only on AUDIT_SCHEMA_MIGRATED). audit_tenant_salt is 1:1 with tenant. audit_export is tenant-scoped. The platform DB holds these tables for all tenants regardless of their db_profile (AUDIT-ADR-01). The country_code column is snapshotted at write time so an exported event is self-contained (no join required to interpret).

2.7 Storage sizing

Per PRD section B.5 baseline: ~1.5 KB per row including indexes, ~2 M events/year on a moderate cabinet, 10-year horizon -> ~30 GB per tenant per decade. Multiplied across the V0 + V1 pilot (Basile + NSIA + 2-3 SME tenants), the audit_log table is expected to reach ~150 GB by year 10. This is within the comfortable operating range of a single PostgreSQL instance on V1 prod hardware (blueprint section 6 - 2x Hetzner CX42, Postgres on a dedicated server). Partitioning by month on business_committed_at is anticipated for V2 when any single tenant crosses ~50 M rows.


3. API Design

3.1 Namespace and conventions

All APIs live under /api/control/audit/... (claimed in PROGRESS.md). Auth = Keycloak JWT with role papillon-platform-admin. Response envelope follows docs/standards/api-guidelines.md. Pagination is cursor-based (D6).

V0 surface (v0.1.0 only): - POST /api/control/audit/exports - degraded CSV export. - GET /api/control/audit/exports/{id} - status + download URL.

V1 full surface adds: - POST /api/control/audit/query - filtered search (AUDIT-US-02). - GET /api/control/audit/cases/{case-type}/{case-key} - by-case lookup (AUDIT-US-03). - POST /api/control/audit/exports (full version) - JSONL + manifest in MinIO.

3.2 POST /api/control/audit/query (V1)

POST /api/control/audit/query
Authorization: Bearer <jwt with role papillon-platform-admin>
Content-Type: application/json

{
  "tenantId": "5a6b7c8d-...",     // one of {tenantId, actorUserId} REQUIRED (AC-02.1)
  "actorUserId": null,
  "from":  "2026-06-01T00:00:00Z",
  "to":    "2026-06-30T23:59:59Z",
  "agencyId": null,
  "eventTypes": ["PAY_CONFIRMED", "NOTIF_FAILURE"],
  "result": null,
  "cursor": null,                  // null for first page
  "limit":  100
}

Response:

{
  "items": [
    {
      "eventId": "01956c3e-...",
      "eventType": "PAY_CONFIRMED",
      "businessCommittedAt": "2026-06-12T14:33:21Z",
      "auditPersistedAt":    "2026-06-12T14:33:21.842Z",
      "tenantId": "5a6b7c8d-...",
      "agencyId": "...",
      "countryCode": "CI",
      "producerModule": "PAY",
      "actorUserId": null,
      "correlationId": "...",
      "customerId": "...",
      "contractId": "...",
      "result": "SUCCESS",
      "payload": { "provider": "ORANGE_MONEY_CI", "amountMinor": 1500000, ... }
    },
    ...
  ],
  "nextCursor": "eyJ0cyI6IjIwMjYtMDYtMTJUMTQ6Mz...",
  "hasMore": true
}

  • Rejections (HTTP 400):
  • tenantId AND actorUserId both null -> {"error":"missing_required_scope","message":"one of tenantId or actorUserId is required"} (FR-04, AC-02.1).
  • Unknown eventTypes entry -> {"error":"unknown_event_type","message":"event type 'FOO' is not in the V1 catalog"} (FR-01).
  • Always logs an AUDIT_QUERY_EXECUTED event after delivery (AC-08.1). Dedup within 60s same-filter (FR-09) keeps the noise floor down.
  • p95 latency budget: 2 s on 10 M rows over a 30-day tenant-scoped window (NFR-05). Achieved via the (tenant_id, business_committed_at DESC) composite index.

3.3 GET /api/control/audit/cases/{case-type}/{case-key} (V1)

GET /api/control/audit/cases/customer/01956c3e-...
GET /api/control/audit/cases/contract/01956c3e-...
GET /api/control/audit/cases/signed-link/01956c3e-...

case-type in {customer, contract, signed-link}. Response carries: - Direct matches (every row whose customer_id / contract_id / signed_link_id equals the key). - Correlation-expanded matches: for every distinct correlation_id in the direct-match set, all other rows sharing that correlation_id are included (AC-03.2). - Results grouped by correlation_id then ordered by business_committed_at ASC within each group (AC-03.3). - Empty result -> HTTP 200 with {"items": [], "reason": "no_events_for_case_key"} (AC-03.4).

3.4 POST /api/control/audit/exports

POST /api/control/audit/exports
Content-Type: application/json

{
  "tenantId": "5a6b7c8d-...",        // REQUIRED (AC-04.3)
  "dateRangeStart": "2026-06-01T00:00:00Z",  // REQUIRED
  "dateRangeEnd":   "2026-06-30T23:59:59Z",  // REQUIRED
  "eventTypes":     null,             // optional, comma-separated
  "correlationId":  null,             // optional
  "caseKey":        null              // optional, {type, value}
}

Returns immediately (HTTP 202):

{
  "exportId":   "01956c3e-...",
  "status":     "PENDING",
  "pollUrl":    "/api/control/audit/exports/01956c3e-..."
}

Background worker streams JSONL into MinIO, computes SHA-256 via a DigestOutputStream, writes manifest.json after the JSONL finalizes, flips status to COMPLETED, fires AUDIT_EXPORT_COMPLETED self-event.

3.5 GET /api/control/audit/exports/{id}

{
  "exportId":   "01956c3e-...",
  "status":     "COMPLETED",
  "scope": {
    "tenantId":       "5a6b7c8d-...",
    "dateRangeStart": "2026-06-01T00:00:00Z",
    "dateRangeEnd":   "2026-06-30T23:59:59Z",
    "eventTypes":     null
  },
  "eventCount":  47218,
  "jsonlSha256": "9b3f1c...",
  "download": {
    "jsonlUrl":    "https://minio.papillon.ci/audit-exports/...?X-Amz-Signature=...",
    "manifestUrl": "https://minio.papillon.ci/audit-exports/...?X-Amz-Signature=...",
    "expiresAt":   "2026-06-30T15:30:00Z"
  }
}

Presigned URLs expire 15 min after delivery. Re-requesting the GET regenerates fresh URLs.

3.6 Performance budgets (NFR alignment)

Endpoint p95 target NFR Anchor
POST /audit/query <= 2 s NFR-05 composite index on (tenant_id, business_committed_at DESC)
GET /audit/cases/{type}/{key} <= 2 s (derived) partial index on case key column
POST /audit/exports (return only) <= 100 ms - enqueue + DB INSERT, no scan
Export job completion (100 000 events) <= 30 s NFR-06 streaming JSONL to MinIO, DigestOutputStream on the fly
GET /audit/exports/{id} <= 100 ms - PK lookup + MinIO presign
Producer-attributed audit latency <= 20 ms NFR-02 publish to outbox = local INSERT into event_publication
Audit freshness (business_committed -> queryable) <= 5 s p95 NFR-03 spring-modulith listener runs in same JVM, async, post-commit

4. Multi-Tenant Strategy

4.1 Tenant resolution

AUDIT inherits resolution from the request boundary (blueprint section 3.2): - Operator/platform-admin path: Keycloak JWT carries tenant_id claim. The OperatorTenantResolutionFilter sets TenantContext before reaching any AUDIT controller. Platform-admin queries may pass an acting_on_tenant_id distinct from the actor's home tenant - FR-04 covers the SQL filter. - Producer-emit path: AuditEventBuilder.fromCurrentContext() reads the current TenantContext and snapshots tenant_id, agency_id, country_code into the event at build time. The audit row is sealed at producer-commit; the listener does not re-resolve.

4.2 Isolation across V1 DB profiles

This is the strongest opinion in the AUDIT architecture: audit_log lives on the platform DB for every tenant, regardless of db_profile (AUDIT-ADR-01).

Profile Tenant business data Audit data
SHARED platform DB platform DB
BYO tenant's own DB platform DB
SCHEMA (V3) platform DB (own schema) platform DB
DATABASE (V3) dedicated DB platform DB

Reasons (all from the PRD's TV-05 and from prd-v0.md section 4 AUDIT line): 1. Operational reliability: if a BYO tenant's DB goes dark (network outage, customer mistake, contract dispute), the platform retains the proof base. AUDIT must never be hostage to a tenant-side outage. 2. Cross-tenant platform-admin queries: AC-08.1 logs platform-admin queries that may target any tenant. Persisting these self-events to per-tenant DBs would split the platform-admin audit trail across N DBs. Untenable. 3. Single source of truth for regulators: a single technical contact (platform admin) can produce a unified export. 4. Simplicity in V0/V1: keeping audit centralized lets us delay the BYO-residency conversation to V2.

V2 will revisit (PRD section A.5 + section C.5). The migration path from "centralized" to "audit-in-tenant-DB + index-in-platform-DB" is mechanical because the schema does NOT change tenant-side (only the partition target changes).

4.3 Tenant scoping at the read boundary

FR-04 enforces: - A query without tenant_id AND without actor_user_id is rejected (AC-02.1). No cross-tenant scans. - A query with tenant_id returns rows where audit_log.tenant_id = :tenantId OR audit_log.acting_on_tenant_id = :tenantId (covers platform-admin self-events acting on the tenant). - A query with actor_user_id only (e.g. "every audit action this admin took") returns rows where actor_user_id = :uid, with acting_on_tenant_id populated where the action targeted a tenant.

4.4 Tenant lifecycle interactions

AUDIT consumes one TENANT event: - TenantOnboarded -> AUDIT creates audit_tenant_salt row (generates fresh 32-byte salt, wraps via envelope encryption, writes the row).

AUDIT does NOT react to TenantSuspended, TenantReactivated, or TenantSoftDeleted. Audit emission continues unchanged for suspended/deleted tenants - their events still need recording, and the PRD AC-07.3 explicitly states "Retention applies even on tenant soft-delete... Customer rows are purged; audit entries (pseudonymized) survive."

4.5 Group/holding (V2 forward-compat)

The schema carries no group_id column today. V2 adds it as a denormalized snapshot column on audit_log if cross-tenant group analytics become a requirement. The path is non-breaking because no existing column moves.


5. Connectivity & Resumability

AUDIT is backend-only in V1 (PRD section C.4). It has no customer-facing surface, no signed-link flow, no slow-3G constraints. V1 PRD section 30 connectivity budgets do not apply.

Two failure modes drive the resumability design:

5.1 Producer -> outbox failure

The producer's audit emission writes a row into event_publication (spring-modulith-events-jdbc) in the same transaction as its business write. If the transaction commits, both rows commit. If the transaction rolls back, both rows roll back. There is no partial state.

Consequence: a producer's business write that did not commit produces NO audit obligation. The PRD AC-01.1 wording ("every committed business action") is honored automatically.

5.2 Outbox -> audit_log INSERT failure (the "ingest degraded" path)

Once an event_publication row exists with completion_date IS NULL, the AUDIT listener is responsible for draining it. Failure modes: 1. AUDIT listener crashes mid-INSERT. Library retries on the next polling cycle. 2. Platform DB is unreachable. Library retries. 3. Bug in AUDIT consumer (e.g. unknown event_type). Library marks the publication as failed after N retries; an alert fires; ops investigates.

The spring-modulith-events-jdbc library exposes IncompleteEventPublications and a scheduled republisher. We configure: - Polling interval: 30 s (well within AC-01.4's "interval <= 5 min"). - Maximum re-delivery age before alerting: 30 min (AC-09.2 escalation threshold). - Backoff: exponential, capped at 5 min between retries.

The audit_outbox_incomplete_count Prometheus gauge is the canonical health signal (NFR-10). When > 100 sustained for 1 min, the producer-side audit_health_state switches to DEGRADED (FR-10) and AUDIT_INGEST_DEGRADED is emitted through the same outbox. Yes, this seems circular - but the AUDIT_INGEST_DEGRADED row is itself just another event_publication row, which the library will deliver when AUDIT recovers (and which is meanwhile observable in Postgres directly). The immediate ops alert is fired from Prometheus on the gauge, not from the audit row - which is why AC-09.1's 60-second SLO is achievable even when AUDIT is fully down.

5.3 Recovery and reconciliation

When AUDIT recovers: 1. The library's scheduler drains all event_publication rows where completion_date IS NULL. 2. Each drained row results in an audit_log INSERT, with business_committed_at taken from the publication payload (the original producer-commit time, not the recovery time). audit_persisted_at is now() at recovery. 3. FR-06 idempotence: if the library re-delivers an event whose (producer_module, producer_outbox_id) already exists in audit_log, the INSERT raises a unique-constraint violation; AUDIT catches it, logs it as a benign duplicate, and marks the publication completed. 4. Once the incomplete-count returns to 0, AUDIT emits AUDIT_RECONCILED with the drain count (AC-01.4 + AC-09.3).

5.4 Export resumability (degraded backstop)

Export jobs are write-once. If a worker crashes mid-export (status RUNNING but completed_at IS NULL for > 1 h), an ops watcher flips the row to FAILED and the platform admin re-issues the export request with a new export_id. Partial MinIO objects are garbage-collected nightly. This is acceptable in V1 because exports are rare (regulator/lawyer requests, not routine).


6. Sequence Diagrams

6.1 Happy path: producer commits, audit lands

sequenceDiagram
    autonumber
    actor Op as Operator (browser)
    participant PROD as Producer module<br/>(e.g. PAY)
    participant DB as Platform DB
    participant OUTBOX as event_publication<br/>(modulith)
    participant AUD as AUDIT listener
    participant LOG as audit_log

    Op->>PROD: HTTP request (e.g. confirm payment)
    PROD->>DB: BEGIN
    PROD->>DB: business write (e.g. UPDATE payment SET status='CONFIRMED')
    PROD->>OUTBOX: INSERT event_publication(PAY_CONFIRMED, payload)
    PROD->>DB: COMMIT
    PROD-->>Op: HTTP 200 (business response, audit not in critical path)
    Note over PROD,OUTBOX: Producer latency budget = 20ms p95 (NFR-02).<br/>The outbox INSERT is a single local Postgres op.
    OUTBOX->>AUD: ApplicationModuleListener picks up<br/>(transactional, after_commit)
    AUD->>LOG: INSERT audit_log(...)
    AUD->>OUTBOX: UPDATE event_publication SET completion_date = now()
    Note over AUD,LOG: Freshness p95 <= 5s (NFR-03).

6.2 Degraded path: AUDIT INSERT fails, recovery

sequenceDiagram
    autonumber
    participant PROD as Producer
    participant OUTBOX as event_publication
    participant AUD as AUDIT listener
    participant LOG as audit_log
    participant MET as Prometheus
    participant OPS as Ops on-call

    PROD->>OUTBOX: publish event (in business TX)
    Note over PROD,OUTBOX: Business TX commits regardless.<br/>AC-01.3: business is NEVER rolled back for audit failure.

    AUD->>LOG: INSERT (fails: DB outage / disk full / constraint)
    AUD->>OUTBOX: completion_date stays NULL
    AUD->>MET: increment audit_ingest_failures_total

    loop every 30s (scheduler)
        AUD->>LOG: INSERT (retry)
        Note over AUD,LOG: Library backoff: 30s, 60s, 120s, 240s, 300s (cap).
    end

    Note over MET: gauge audit_outbox_incomplete_count rises
    MET->>OPS: Alert: AUDIT_INGEST_DEGRADED (within 60s, AC-09.1)
    Note over MET,OPS: Alert payload: producer_module, error_class,<br/>queued_count, last_business_committed_at.

    Note over LOG: DB recovers
    AUD->>LOG: INSERT (succeeds)
    AUD->>OUTBOX: drain queue
    AUD->>OUTBOX: publish AUDIT_RECONCILED with drained count
    OUTBOX->>AUD: deliver AUDIT_RECONCILED
    AUD->>LOG: INSERT AUDIT_RECONCILED row
    AUD->>MET: gauge returns to 0 -> alert auto-resolves (AC-09.3)

6.3 Export bundle (V1 full flow)

sequenceDiagram
    autonumber
    actor PA as Platform admin
    participant API as POST /audit/exports
    participant EXP as audit_export table
    participant WK as Export worker (async)
    participant LOG as audit_log
    participant MIN as MinIO bucket<br/>audit-exports
    participant OUT as outbox

    PA->>API: POST {tenantId, dateRange, ...}
    API->>EXP: INSERT row (status=PENDING)
    API-->>PA: 202 {exportId, status=PENDING, pollUrl}

    WK->>EXP: UPDATE status=RUNNING, started_at
    WK->>LOG: SELECT * cursor scan (tenant_id index)
    loop streaming
        WK->>MIN: PUT object chunk (JSONL)
        Note over WK,MIN: DigestOutputStream computes SHA-256 inline.
    end
    WK->>MIN: PUT manifest.json {sha256, scope, eventCount, ...}
    WK->>EXP: UPDATE status=COMPLETED, jsonlSha256, eventCount, ...
    WK->>OUT: publish AUDIT_EXPORT_COMPLETED
    OUT->>LOG: INSERT AUDIT_EXPORT_COMPLETED row

    PA->>API: GET /audit/exports/{id}
    API->>MIN: presign URLs (15 min TTL)
    API-->>PA: 200 {status=COMPLETED, jsonlUrl, manifestUrl}
    PA->>MIN: GET jsonl (presigned)
    PA->>MIN: GET manifest (presigned)

6.4 Tenant onboarding -> salt provisioning

sequenceDiagram
    autonumber
    participant T as TENANT module
    participant OUT as outbox
    participant AUD as AUDIT listener
    participant SALT as audit_tenant_salt
    participant KEK as env AUDIT_KEK

    T->>OUT: publish TenantOnboarded(tenantId, ...)
    OUT->>AUD: deliver TenantOnboarded
    AUD->>AUD: SecureRandom.nextBytes(32) -> raw_salt
    AUD->>AUD: SecureRandom.nextBytes(32) -> dek
    AUD->>AUD: encrypt raw_salt with dek (AES-GCM) -> salt_ciphertext
    AUD->>KEK: read KEK from env
    AUD->>AUD: encrypt dek with KEK (AES-KW) -> wrapped_dek
    AUD->>SALT: INSERT audit_tenant_salt(tenant_id, salt_ciphertext, wrapped_dek, kek_version=1)
    AUD->>AUD: zero raw_salt + dek in heap
    Note over AUD,SALT: PiiHasher cache is now warm for this tenant.

6.5 Producer uses PiiHasher

sequenceDiagram
    autonumber
    participant NOTIF as NOTIF module
    participant HASH as PiiHasher (AUDIT bean)
    participant CACHE as in-memory salt cache (5min TTL)
    participant SALT as audit_tenant_salt
    participant OUT as outbox

    Note over NOTIF: about to send SMS to +225 07 12 34 56 78
    NOTIF->>HASH: hash("+225 07 12 34 56 78")
    HASH->>CACHE: get salt for current tenant
    alt cache miss
        CACHE->>SALT: SELECT salt_ciphertext, wrapped_dek for tenant
        CACHE->>CACHE: unwrap DEK with KEK, decrypt salt, store in cache
    end
    HASH->>HASH: SHA-256(salt || normalized_phone) -> hex_digest
    HASH-->>NOTIF: hex_digest (zero raw, zero salt from caller's frame)
    NOTIF->>OUT: publish NOTIF_SENDING with payload.phone_hash = hex_digest

7. Security

7.1 Authentication / authorization

  • Read APIs: Keycloak JWT with role papillon-platform-admin. No tenant-admin role can read AUDIT in V1 (PRD section A.2).
  • Write path: in-process Spring Modulith calls. No external HTTP write surface. Producers are trusted in-process collaborators. The Modulith boundary verifier (ApplicationModules.verify()) blocks any module from depending on AUDIT's internal classes - they only see the PiiHasher named-interface and the catalog enum.
  • Deploy path: Flyway runs under papillon_audit_admin credentials supplied by the deploy pipeline. The runtime JVM never gets these credentials.

7.2 PostgreSQL role separation (AUDIT-ADR-07)

Three roles. V0.0.0 ships the first two; V1 adds the third.

Role Grants on audit_log Grants on audit_tenant_salt Grants on audit_export DDL Used by Available
papillon_app INSERT, SELECT (no UPDATE, no DELETE) SELECT SELECT, INSERT, UPDATE - Spring Boot runtime v0.0.0
papillon_audit_reader SELECT - SELECT - Query API + Export worker V1
papillon_audit_admin ALL incl. DDL ALL ALL YES Flyway (deploy only) v0.0.0

V0.0.0 reality: the runtime uses papillon_app (which has INSERT+SELECT on audit_log). The query API does not exist in V0, so the absence of papillon_audit_reader is moot. The principle that the app can never UPDATE/DELETE audit_log holds from Day 1.

V1 adds papillon_audit_reader. The query API connection pool uses this role exclusively. The benefit is structural: a bug in the query API code that wrote UPDATE audit_log would be rejected by Postgres before doing damage. Defense in depth.

Migrations to create roles:

-- V001__roles_audit.sql (v0.0.0)
CREATE ROLE papillon_app  LOGIN PASSWORD :app_password;
CREATE ROLE papillon_audit_admin LOGIN PASSWORD :admin_password;

GRANT INSERT, SELECT ON audit_log         TO papillon_app;
GRANT SELECT          ON audit_tenant_salt TO papillon_app;
GRANT SELECT, INSERT, UPDATE ON audit_export TO papillon_app;
GRANT USAGE ON SCHEMA public TO papillon_app;

-- explicitly NO UPDATE, NO DELETE on audit_log for papillon_app

GRANT ALL ON audit_log, audit_tenant_salt, audit_export TO papillon_audit_admin;

7.3 Pseudonymization (AUDIT-ADR-04)

The full rationale is in docs/kb/audit-salt-storage-options.md. Operational summary:

  • Each tenant has one row in audit_tenant_salt. The row is created at TenantOnboarded reception (section 6.4).
  • The salt is never persisted in cleartext, never logged, never included in any error envelope, never included in any export.
  • The PiiHasher bean is the only callable surface. Producers consume String hash(String value) and get back a hex string.
  • Hash algorithm: SHA-256, input format salt_bytes || UTF-8(canonical_form(value)). canonical_form strips whitespace and normalizes case (lowercase) for emails; for phones it normalizes to E.164.
  • KEK lives in env var AUDIT_KEK (32-byte base64) in v0.0.0. V1+ may move to a hosted KMS via the same envelope-encryption library.

V2 work flagged: - Salt rotation under kek_version discriminator. - Cryptographic erasure ("forget" a tenant's salt to render all their audit fingerprints irreversible).

7.4 Encryption in transit / at rest

  • TLS terminates at the Coolify reverse proxy (blueprint section 3.3). All /api/control/audit/... traffic is TLS.
  • Postgres volume is encrypted at the deploy layer (Hetzner-managed). No additional column-level encryption on audit_log itself; pseudonymization is the V1 compensating control for assure PII (PRD AC-06).
  • MinIO objects are server-side encrypted (SSE-S3). Presigned URLs carry no payload, only an authorization grant; they expire 15 min after issuance.

7.5 Self-auditing (AUDIT-US-08)

V1 surface: - Every POST /audit/query invocation produces an AUDIT_QUERY_EXECUTED row (AC-08.1), deduplicated within 60s same-filter (FR-09). - Every export completion produces AUDIT_EXPORT_COMPLETED (AC-08.2, AC-04.5). - Every audit_log schema migration produces AUDIT_SCHEMA_MIGRATED written by the Flyway Java callback under papillon_audit_admin (AC-08.3, AUDIT-ADR-06).

7.6 Honest scope (PRD AC-05.4)

AUDIT does not defend against: - A malicious DBA with direct Postgres access using papillon_audit_admin credentials. - A compromised infrastructure operator with shell access to the platform. - A KEK leak (if AUDIT_KEK env var is exfiltrated, salts can be unwrapped offline).

These are V2 problems (cryptographic tamper-evidence + HSM-backed key custody). The V1 claim under section 16.5 is appropriately scoped: "we did not silently rewrite history from inside the running application."

7.7 Secret management summary

Secret Lives in Rotated Audit row on rotation?
papillon_app password env / Coolify secret per deploy no
papillon_audit_admin password CI/CD secret only per deploy no
papillon_audit_reader password (V1) env per deploy no
AUDIT_KEK env / Coolify secret (V2: KMS) manual AUDIT_KEK_ROTATED (V2 new event_type)
Per-tenant DEKs audit_tenant_salt.wrapped_dek manual V2 V2 new event_type

8. Performance & Bandwidth Budget

8.1 Producer-side budget (NFR-02)

The producer pays at most: - One INSERT into event_publication inside its existing business transaction (~0.5-1.5 ms on a warm pool). - One PiiHasher.hash() call per PII field. First call per tenant per JVM lifetime pays a Postgres SELECT + KEK unwrap + AES decrypt (~5-8 ms). Subsequent calls within the 5 min cache TTL are pure SHA-256 (~10 us).

Worst-case producer latency added: ~10 ms in steady state, well under NFR-02's 20 ms budget.

8.2 Freshness (NFR-03)

Spring-modulith-events-jdbc with @ApplicationModuleListener runs in-JVM, post-commit, async. Typical delay business_committed -> audit_persisted is 50-200 ms on a healthy system. p95 <= 5 s is comfortable.

8.3 Query path (NFR-05)

The composite (tenant_id, business_committed_at DESC) index covers the canonical AUDIT-US-02 query. For 10 M rows across all tenants and a typical tenant share of ~500 K rows, a 30-day window selects <= 50 K rows, returned in 100 page-sized chunks. Postgres serves this from the index without table scan when the SELECT list is column-aligned with the index leaf payload. Empirically achievable < 500 ms even on V1 hardware.

8.4 Export path (NFR-06)

Streaming via cursor. 100 000 rows at avg 1.5 KB = 150 MB JSONL. PostgreSQL cursor scan throughput ~10-30 MB/s. MinIO PUT throughput on Hetzner-internal network ~50+ MB/s. The bottleneck is the SHA-256 computation, which on modern CPUs runs at ~500 MB/s. End-to-end: 10-15 s for 100 000 rows. Within 30 s NFR.

8.5 Storage growth

See section 2.7. ~30 GB/tenant/decade is comfortable. Partitioning by month on business_committed_at enters consideration around ~50 M rows in any single tenant.

8.6 Bandwidth (N/A for V1)

No customer-facing surface. The V1 PRD section 30 budget (<200 KB gzipped) does not apply to AUDIT.


9. Migration Strategy

9.1 Flyway file layout (per PROGRESS.md D9)

backend/src/main/resources/db/migration/platform/
├── V001__audit_log_init.sql           [v0.0.0]
├── V002__audit_tenant_salt.sql        [v0.0.0]
├── V003__audit_export.sql             [v0.0.0]
├── V004__audit_roles.sql              [v0.0.0]
├── V005__audit_indexes.sql            [v0.0.0]
├── V006__audit_query_log_dedup.sql    [V1, supports FR-09]
└── ...

All migrations target the platform DB only. AUDIT does NOT install schema on BYO tenant DBs (AUDIT-ADR-01). The blueprint's D9 split (platform/ vs tenant/) is honored: AUDIT contributes to platform/ exclusively.

9.2 AUDIT_SCHEMA_MIGRATED callback (AUDIT-ADR-06)

@Component
public class AuditSchemaMigratedCallback extends BaseFlywayCallback {

    @Override
    public boolean supports(Event event, Context context) {
        return event == Event.AFTER_EACH_MIGRATE;
    }

    @Override
    public void handle(Event event, Context context) {
        // context.getConnection() is bound to papillon_audit_admin, the only role with
        // INSERT permission on audit_log AT THE TIME OF DEPLOY (the runtime app uses papillon_app).
        // The runtime application has no way to invoke this code path - it runs only inside Flyway.
        var migration = context.getMigrationInfo();
        try (var ps = context.getConnection().prepareStatement("""
            INSERT INTO audit_log (
                event_id, event_type, event_schema_version,
                business_committed_at, audit_persisted_at,
                tenant_id, producer_module, result, payload)
            VALUES (?, 'AUDIT_SCHEMA_MIGRATED', 1, now(), now(),
                    NULL, 'deploy', 'SUCCESS', ?::jsonb)
        """)) {
            ps.setObject(1, UuidV7.generate());
            ps.setString(2, """
                {"migration_id":"%s","description":"%s","checksum":"%s"}
                """.formatted(migration.getVersion(), migration.getDescription(), migration.getChecksum()));
            ps.executeUpdate();
        } catch (SQLException e) {
            throw new FlywayException("Failed to record AUDIT_SCHEMA_MIGRATED", e);
        }
    }
}

The callback runs in the same transaction as the migration body (Flyway default for SQL migrations). Either both commit, or both rollback. There is no partial state.

9.3 Reversibility

  • audit_log is never rolled back schema-wise once data lands (CIMA + ARTCI retention).
  • Adding new columns is permitted (V1 may add e.g. a purge_at discriminator for V2 archival prep), with V### Flyway migrations. The Java callback records each one.
  • Reverting a release that included an audit_log column addition requires a forward-fix migration that drops the column - and that drop must itself be reviewed under AC-05.3 with the audit-schema label and founder sign-off.

9.4 BYO tenant onboarding migrations

When a tenant is provisioned with db_profile = BYO (v0.0.4+), the TENANT module runs db/migration/tenant/V###__*.sql against the tenant's own DB. No AUDIT migration runs there. AUDIT's tables are on platform only.


10. Integration Points

10.1 spring-modulith-events-jdbc (locked stack)

The library is already configured for TENANT and AUTH (D4 + D7). AUDIT joins the same configuration: - event_publication table on platform DB. - @ApplicationModuleListener on the AUDIT consumer. - IncompleteEventPublications scheduled republisher every 30 s.

No new infrastructure required.

10.2 MinIO (locked stack)

A new bucket audit-exports is provisioned on first deploy: - Object key pattern: audit-exports/{tenant_short_name}/{export_id}.jsonl and .../manifest.json. - Bucket lifecycle: object retention 10 years (matches NFR-04). Object lock NOT enabled in V1 (WORM is V2). - Access via the existing MinIO SDK already used for quittances (blueprint section 3.6).

10.3 PostgreSQL (locked stack)

Platform DB only. AUDIT contributes new tables + roles, no new server instance. Connection pool sizing for V1: dedicated 5-connection pool for the export worker (long-running cursor reads), shared with the rest of the app for the audit consumer.

10.4 Prometheus + Grafana (already in stack)

Micrometer instruments: - audit_outbox_incomplete_count (gauge, no tenant tag - it is a global health signal). - audit_oldest_incomplete_age_seconds (gauge). - audit_freshness_seconds (histogram, business_committed_at -> audit_persisted_at, tagged by producer_module). - audit_ingest_failures_total (counter, tagged by producer_module, error_class). - audit_query_latency_seconds (histogram, tagged by endpoint). - audit_export_duration_seconds (histogram, tagged by event_count_bucket). - audit_export_bytes_total (counter, tagged by tenant_id for capacity planning).

V0.1.0 ships the metrics. V1 wires the Grafana alert rules.

10.5 No external dependencies beyond locked stack

  • No new SMS provider.
  • No new payment provider.
  • No new identity provider.
  • No new file-format library beyond Jackson (already in stack for JSON serialization).

10.6 Cross-module event contracts emitted BY AUDIT

Event Trigger Consumers Available
AUDIT_INGEST_DEGRADED FR-10 threshold breach ops (alert) v0.1.0
AUDIT_RECONCILED Drain queue back to 0 ops (auto-resolve) v0.1.0
AUDIT_EXPORT_COMPLETED Export worker finishes platform admin v0.1.0
AUDIT_QUERY_EXECUTED Query API hit self-audit V1
AUDIT_SCHEMA_MIGRATED Flyway callback fires self-audit v0.0.0

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.

Slices are ordered by dependency. Concurrency-tier rule applied: Tier 0 (the dev-environment slice) is serial.

Slice AUDIT-S0 [V0.0.0] [Tier 0] - Dev env + test infrastructure

Scope: Wire AUDIT into the existing local + CI dev environment. New external deps (the audit-exports MinIO bucket, the new Postgres roles) are introduced here.

Files: - backend/build.gradle.kts - add AUDIT's aws-sdk-s3 dependency (MinIO compat); spring-modulith-events-jdbc + spring-jdbc already on the single backend module. AUDIT is a Spring Modulith package, NOT a separate Gradle module (java-spring-guidelines.md §6). - docker-compose.yml - confirm MinIO service has the audit-exports bucket auto-created (init container or boot script). - backend/src/test/resources/application-test.yml - Testcontainers config (AUDIT adds keys to the shared file). - backend/src/test/java/com/altarys/papillon/pcs/controlplane/audit/AuditTestContainersBase.java - PostgreSQL + MinIO containers fixture. - backend/src/test/java/com/altarys/papillon/pcs/controlplane/audit/AuditModuleBoundariesTest.java - ApplicationModules.verify().

Estimated complexity: M

Acceptance: - ./gradlew :backend:check is green. - docker compose up produces a running MinIO with audit-exports bucket pre-created. - audit_log table does NOT yet exist (no Flyway migrations in this slice; the next slice introduces them).

Dependencies: TENANT-S0 (platform DB exists), blueprint already locked.

Slice AUDIT-001 [V0.0.0] - Audit schema + outbox consumer + PiiHasher Day 1

Scope: Everything the V0.0.0 banner of audit-log-prd.md mandates. Schema complete (PRD section B.5), outbox listener wired, Pii salt provisioning on TenantOnboarded, Flyway Java callback firing AUDIT_SCHEMA_MIGRATED. Catalog enum exposes all section B.1 event types so producers can compile against them even before their module activates real emission.

Files: - backend/src/main/resources/db/migration/platform/V001..V005__*.sql (per section 9.1). - com.altarys.papillon.pcs.controlplane.audit.AuditEventType (enum, all section B.1 entries). - com.altarys.papillon.pcs.controlplane.audit.AuditEvent (record / sealed type per family). - com.altarys.papillon.pcs.controlplane.audit.AuditEventBuilder (consumed by producers). - com.altarys.papillon.pcs.controlplane.audit.api.PiiHasher (named-interface bean, Modulith-exposed). - com.altarys.papillon.pcs.controlplane.audit.internal.PiiHasherImpl (envelope encryption + cache). - com.altarys.papillon.pcs.controlplane.audit.internal.AuditOutboxListener (@ApplicationModuleListener). - com.altarys.papillon.pcs.controlplane.audit.internal.AuditSchemaMigratedCallback (Flyway). - com.altarys.papillon.pcs.controlplane.audit.internal.TenantSaltProvisioner (consumes TenantOnboarded). - Integration tests for: schema migration -> callback fires; producer-commit -> audit row appears; PiiHasher determinism; tenant isolation on writes.

Estimated complexity: L (split candidate - see below).

Acceptance: - AC-01.1 zero-loss test: kill the JVM during a producer business commit (after business INSERT but before AUDIT INSERT); on restart, the audit row appears within freshness budget. - AC-05.1 role gates: connect as papillon_app and attempt UPDATE/DELETE on audit_log; receive permission denied. - AC-08.3 schema migration: run a Flyway migration that touches audit_log; verify exactly one AUDIT_SCHEMA_MIGRATED row appears, written by papillon_audit_admin. - FR-06 idempotence: trigger the same outbox event twice; assert only one row in audit_log. - PiiHasher determinism across two JVM invocations with the same salt.

Split candidate (if PO needs to slice further): - AUDIT-001a: schema + roles + Flyway callback. - AUDIT-001b: outbox listener + AuditEventBuilder + catalog enum + producer stubs in TENANT/AUTH/NOTIF/ING. - AUDIT-001c: PiiHasher + TenantSaltProvisioner + envelope encryption.

Dependencies: TENANT-001 (TenantOnboarded must exist), AUTH-001 (Keycloak roles exist for the admin role wiring even if no API yet uses them), AUDIT-S0.

Slice AUDIT-002 [V0.0.1] - Real emission on PAY + NOTIF

Scope: PAY-001 and NOTIF-002 producers swap from stubs to real AuditEventBuilder calls. NOTIF starts consuming PiiHasher for the phone_hash field in NOTIF_SENDING / NOTIF_FAILURE. No new AUDIT-side code beyond test fixtures.

Files (AUDIT-side): - Integration tests asserting PAY_INITIATED, PAY_CONFIRMED, PAY_FAILED rows appear with full mandatory fields per FR-02. - Integration tests asserting NOTIF_SENDING, NOTIF_FAILURE, NOTIF_SUPPRESSED_OPT_OUT rows include phone_hash but never raw phone.

Files (producer-side, owned by PAY + NOTIF stories): - PAY-001 emits AuditEvent at PAY.confirm() / PAY.fail() boundaries. - NOTIF-002 emits at NOTIF.send() / NOTIF.fail() boundaries.

Estimated complexity: S (AUDIT-side); the real work is in PAY-001 + NOTIF-002.

Acceptance: - Audit row count = expected count after a synthetic 100-payment + 100-SMS run. - No payload field contains a string matching \+225\d{10} (regex defensive check).

Dependencies: AUDIT-001, PAY-001, NOTIF-002.

Slice AUDIT-003 [V0.1.0] - Compliance report stubs + ingest metrics

Scope: V0.1.0 banner deliverable. POST /api/control/audit/exports endpoint returning a degraded CSV export to MinIO (no manifest, no SHA-256 in V0.1.0). Prometheus metrics exposed. Alerting rules NOT yet wired (V1).

Files: - com.altarys.papillon.pcs.controlplane.audit.web.AuditExportController (POST + GET). - com.altarys.papillon.pcs.controlplane.audit.internal.CsvExportWorker (async @Job). - com.altarys.papillon.pcs.controlplane.audit.internal.AuditMetrics (Micrometer instruments per section 10.4). - backend/src/main/resources/db/migration/platform/V006__audit_export.sql already in AUDIT-001; this slice fills the worker. - Integration test: POST /audit/exports -> poll until COMPLETED -> download CSV via presigned URL -> assert row count.

Estimated complexity: M

Acceptance: - Export of 1 000 events completes within 5 s. - Presigned URL serves the CSV; URL expires 15 min after issuance. - AUDIT_EXPORT_COMPLETED self-event written. - audit_outbox_incomplete_count metric scrapeable by Prometheus.

Dependencies: AUDIT-001 (the audit_log table exists), AUDIT-002 (real data exists to export).

Slices deferred to V1 (out of V0 scope)

Listed here for PROGRESS.md downstream tracking; NOT decomposed into V0 stories.

Slice ID Story Tag
AUDIT-004 Filter query API (AUDIT-US-02) + dedup self-event (FR-09) [V1]
AUDIT-005 By-case lookup API (AUDIT-US-03) + correlation expansion [V1]
AUDIT-006 Full JSONL+manifest export (AUDIT-US-04) + SHA-256 [V1]
AUDIT-007 papillon_audit_reader role + query API uses it [V1]
AUDIT-008 Defensive PII scan at AUDIT boundary (FR-03 alinea 2) [V1]
AUDIT-009 Real ingest-degradation alerting + Grafana rules (US-09) [V1]

V2 tracked separately in PRD section A.5 (tenant-side viewer, crypto tamper-evidence, BYO residency, salt rotation, crypto-erasure, auto-purge).


12. Technical Risks

# Risk Likelihood Impact Mitigation
R1 Producer forgets to emit audit at a catalog-bound event boundary Medium High Producer-side integration test (per producer story) asserts every catalog event emits when its business action commits. REVIEWER checklist line. AUDIT-side metrics: audit_ingest_zero_lag_count per producer; sudden drop alerts.
R2 spring-modulith-events-jdbc event_publication table grows unbounded during sustained AUDIT outage Medium Medium audit_outbox_incomplete_count alert at 1 000 (AC-09.2). Manual ops runbook documents drain procedure. Capacity headroom on platform DB is generous (the table averages ~200 bytes per row).
R3 Salt cache poisoning via stale entries surviving a rotation Low Medium Cache is in-process, scoped per-tenant, TTL 5 min. V2 rotation triggers JVM-wide cache flush via Spring event. v0.0.0/v1 has no rotation, so the risk surface is academic until V2.
R4 MinIO unavailable mid-export Low Low Export worker catches IO failure, flips audit_export.status = FAILED, emits ops alert. Platform admin retries with a new export_id. No partial bundle persisted.
R5 spring-modulith-events-jdbc payload size explosion (a producer puts a 1 MB document in payload) Medium Medium Enforce 32 KB max on AuditEventBuilder.payload(...) at the producer-facing API. Truncate user_agent to 500 chars. Reject (FR-02 rejection path) anything over the cap.
R6 audit_log contention under campaign-burst writes (1 000 simultaneous PAY+NOTIF events) Low Medium Single-table INSERT with primary index on UUIDv7 is monotonically increasing -> last-page hotspot on the BTree. Mitigations: PG bouncer + monotonic-clock-based UUIDv7 with random low bits. If real, V2 monthly partitioning by business_committed_at.
R7 Flyway callback failure leaves migration applied but AUDIT_SCHEMA_MIGRATED not written Low Medium Flyway runs migration + callback in the same transaction (Flyway default for SQL migrations + Java callbacks under transactionalLock). If the callback throws, the entire migration rolls back. Verified in AUDIT-S0 integration test.
R8 SQL injection in V1 query API filters Low High All filter values are bound parameters (Spring Data JDBC @Query with named params). Filter keys are validated against an allowlist enum before reaching SQL. No string concatenation anywhere in the query construction path.
R9 A producer emits a raw PII string in payload (PiiHasher not consumed) Medium High Day 1: PiiHasher is the only easy path (the AuditEventBuilder API for sensitive fields takes a Hashed<...> type that is only constructable through PiiHasher). V1 adds the defensive scan at the AUDIT INSERT boundary (FR-03 alinea 2).
R10 KEK leak (env var exfiltrated) Low High Documented in PRD AC-05.4 honest scope. Mitigation: V2 hosted KMS. V1 mitigation = ops discipline (secrets in Coolify env, not in repo, not in deploy logs).
R11 Test data accidentally lands in prod audit_log via a misconfigured staging deploy Low High Boot-time sanity check: if AUDIT_ENV=prod but the migration history shows a V*__seed_* migration applied, refuse to start. Deploy pipeline asserts no seed migrations exist on prod.
R12 Cross-tenant data leak via FR-04 bug Low Critical Integration test: write events for tenant A + B; for every read endpoint, assert the response excludes B's rows when scoped to A. Run on every PR.

13. Test Infrastructure

13.1 TestContainers fixture

@Testcontainers
public abstract class AuditTestContainersBase {
    @Container
    protected static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("papillon_test")
            .withUsername("test").withPassword("test");

    @Container
    protected static final MinIOContainer MINIO =
        new MinIOContainer("minio/minio:latest")
            .withUserName("test").withPassword("testtest");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        r.add("spring.datasource.username", POSTGRES::getUsername);
        r.add("spring.datasource.password", POSTGRES::getPassword);
        r.add("audit.minio.endpoint", MINIO::getS3URL);
        r.add("audit.kek", () -> "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF");
    }
}

13.2 Sample tenant-isolation test (the canonical pattern)

@SpringBootTest
class AuditTenantIsolationTest extends AuditTestContainersBase {

    @Autowired AuditTestHelpers helpers;

    @Test
    void writes_are_isolated_per_tenant() {
        UUID tenantA = helpers.provisionTenant("Cabinet A");
        UUID tenantB = helpers.provisionTenant("Cabinet B");

        helpers.commitBusinessActionAs(tenantA, () ->
            helpers.emit(AuditEventType.NOTIF_SENDING, customerId(), "+22507111111"));
        helpers.commitBusinessActionAs(tenantB, () ->
            helpers.emit(AuditEventType.NOTIF_SENDING, customerId(), "+22507222222"));

        helpers.awaitAuditDrain();

        List<AuditRow> aRows = helpers.queryRaw("SELECT * FROM audit_log WHERE tenant_id = ?", tenantA);
        List<AuditRow> bRows = helpers.queryRaw("SELECT * FROM audit_log WHERE tenant_id = ?", tenantB);

        assertThat(aRows).hasSize(1);
        assertThat(bRows).hasSize(1);
        assertThat(aRows.getFirst().payload().get("phone_hash"))
            .isNotEqualTo(bRows.getFirst().payload().get("phone_hash")); // different salts
    }
}

13.3 Sample "AC-01.1 zero-loss" test

@Test
void audit_eventually_lands_after_audit_db_outage() throws Exception {
    UUID tenantId = helpers.provisionTenant("Cabinet");
    helpers.pauseMinioOrPostgresInsertCapability(); // simulate AUDIT downstream outage

    helpers.commitBusinessActionAs(tenantId, () ->
        helpers.emit(AuditEventType.PAY_CONFIRMED, contractId(), 1_500_000));

    // Business action committed. Audit row not yet present.
    assertThat(helpers.countAuditRowsFor(tenantId)).isZero();

    helpers.resumeMinioOrPostgresInsertCapability();
    helpers.awaitAuditDrainWithin(Duration.ofMinutes(2));

    assertThat(helpers.countAuditRowsFor(tenantId)).isOne();
    assertThat(helpers.queryRaw("SELECT business_committed_at, audit_persisted_at FROM audit_log").getFirst())
        .satisfies(row -> assertThat(row.businessCommittedAt()).isBefore(row.auditPersistedAt()));
}

13.4 Sample AC-05.1 role-gate test

@Test
void papillon_app_role_cannot_update_audit_log() throws SQLException {
    helpers.commitBusinessActionAs(tenantId, () -> helpers.emit(...));
    helpers.awaitAuditDrain();

    try (var conn = helpers.openConnectionAs("papillon_app")) {
        var ps = conn.prepareStatement("UPDATE audit_log SET result = 'FAILURE'");
        assertThatThrownBy(ps::executeUpdate)
            .isInstanceOf(SQLException.class)
            .hasMessageContaining("permission denied");
    }
}

13.5 Mocked external services

  • MinIO: real Testcontainer (no mock).
  • PostgreSQL: real Testcontainer.
  • KEK: 64-char hex in test env var.
  • spring-modulith-events-jdbc: real library, real outbox table - no mock.
  • Prometheus: in-memory SimpleMeterRegistry for unit tests; no scrape endpoint exercised in integration tests in V0/V1.

13.6 Low-bandwidth resumability sample

Not applicable: AUDIT has no customer-facing surface. The "resumability" property defended by AUDIT is the producer-to-audit ingest resumability under AUDIT outage (see section 13.3 above).


14. Local Dev Environment

14.1 docker-compose.yml services consumed by AUDIT

No new services beyond those locked in the blueprint: - postgres (platform DB). - minio with init container creating bucket audit-exports. - prometheus + grafana (already in stack via blueprint section 3.6). - redis (NOT consumed by AUDIT; here for completeness). - Spring Boot app (the modulith).

AUDIT does NOT introduce a new container.

14.2 Environment variables

Var v0.0.0 Notes
AUDIT_KEK required 64-char hex (32 bytes). Generated via openssl rand -hex 32.
AUDIT_MINIO_BUCKET audit-exports bucket auto-created at boot if missing
AUDIT_EXPORT_PRESIGN_TTL_SECONDS 900 15 min presigned URL
AUDIT_OUTBOX_POLL_INTERVAL_MS 30000 spring-modulith republisher interval
AUDIT_DEGRADED_THRESHOLD 100 incomplete-count threshold for FR-10
AUDIT_SALT_CACHE_TTL_SECONDS 300 5 min in-memory salt cache

14.3 Smoke verification

# Boot the stack
docker compose up -d

# Apply migrations (Flyway runs at app boot; smoke command exercises the callback)
./gradlew :backend:bootRun -P profile=local

# In another terminal: run the smoke test
./gradlew :backend:integrationTest --tests AuditSmokeTest

# Expected output:
#   - audit_log table exists
#   - one AUDIT_SCHEMA_MIGRATED row per applied migration (visible to SELECT)
#   - PiiHasher returns deterministic hashes within a tenant
#   - papillon_app role rejects UPDATE/DELETE

14.4 Onboarding checklist for a new contributor touching AUDIT

  1. Read docs/prd/audit-log-prd.md (PRD, ground truth).
  2. Read this architecture document (you are here).
  3. Skim docs/kb/audit-salt-storage-options.md (background on the salt-store decision).
  4. Confirm AUDIT_KEK is set in the local .env (generate via openssl rand -hex 32; never commit).
  5. Run the smoke command above; verify green.
  6. Read the story file at docs/stories/audit-log/AUDIT-NNN.md for the slice you are implementing.

Appendix A. Cross-module open questions surfaced

These are NEW open questions raised by this session. Existing PRD OQs (A.6 in audit-log-prd.md) are unchanged.

ID Question Owner Blocker?
OQ-X3 correlation_id origin = OP at OP_CAMPAIGN_VALIDATED, propagated through AUTH -> NOTIF -> CP -> PAY. To be confirmed in OP/NOTIF/CP/PAY architecture sessions. (AUDIT-ADR-08.) OP + NOTIF + CP + PAY architects No (default locked)
OQ-X4 acting_on_tenant_id filter on AUDIT-US-02 query API: do we also include rows where tenant_id = :scope_tenant_id AND the actor was platform admin? Current AUDIT-ADR: yes (FR-04). ARCHITECT review No
OQ-X5 Defensive PII scan at AUDIT INSERT boundary (FR-03 alinea 2): in scope for V1 (AUDIT-008) but not before. Confirm acceptable in pilot. Founder No

The existing PRD OQs A.6 OQ-01 (BILL fiscal events in catalog), OQ-02 (erasure-right policy), OQ-04 (alert routing recipient) and OQ-05 (correlation_id origin - resolved here, pending cross-module confirmation) remain open as documented in the PRD.


End of architecture document. Next steps: founder approval (workflow stage 4), then DESIGNER N/A (no UI in V1), then PRODUCT_OWNER decomposes AUDIT-S0 / AUDIT-001 / AUDIT-002 / AUDIT-003 into story files under docs/stories/audit-log/.