Architecture - audit-log (AUDIT) module - V1¶
Module prefix:
AUDIT. Plane: Control . Version scope:[V1](with V0 deltas in section 0) . Country scope V1:CIonly 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.mdsection 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_logcomplet V1 dès v0.0.0. Toutes les colonnes de section B.5 du PRD (event_idUUIDv7,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 suraudit_logentre 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 touteALTER TABLEsur une table monotone croissante. - Outbox
spring-modulith-events-jdbccâblé Day 1. Tout producteur d'audit publie un domain event dans la même transaction que son business write (anchor D4) ; un@ApplicationModuleListenercôté AUDIT consomme depuis l'event_publicationtable et INSERT dansaudit_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+ beanPiiHasherexposé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. Voirdocs/kb/audit-salt-storage-options.mdpour le pourquoi de cette option vstenant_settingsou 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_appn'a JAMAISUPDATEniDELETEsuraudit_log, dès v0.0.0. La V1 ajoute en plus un rôlepapillon_audit_reader(SELECT only) consommé par les query APIs (story AUDIT-005 V1). Le rôlepapillon_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_MIGRATEDDay 1. Posté enafterMigrate, sous le rôlepapillon_audit_admin, dans la même transaction que la migration. Atomique. Le runtime applicatif (rôlepapillon_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
PiiHasherest 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_COMPLETEDau 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 requisouPRÉLIMINAIREdu memo F5 n'est levé par le découpage V0. Les notes[LEGAL-REVIEW]sur les durées applicatives (memo F5PRÉ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
TenantOnboardedfires, AUDIT must create theaudit_tenant_saltrow for that tenant. This is the only inbound coupling. - Every other module depends on AUDIT for:
- the
PiiHasherbean (compile-time dependency, in-process call), - the catalog of
event_typeconstants 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_publicationis 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 RESTRICTprevents tenant hard-delete (which is already forbidden by CIMA + ARTCI retention; this is belt-and-suspenders).kek_versionis the discriminator for future V2 KEK rotation. v0.0.0 ships withkek_version = 1, KEK in env varAUDIT_KEK.- The salt is generated at
TenantOnboardedreception (32 bytes fromSecureRandom).
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):
tenantIdANDactorUserIdboth null ->{"error":"missing_required_scope","message":"one of tenantId or actorUserId is required"}(FR-04, AC-02.1).- Unknown
eventTypesentry ->{"error":"unknown_event_type","message":"event type 'FOO' is not in the V1 catalog"}(FR-01). - Always logs an
AUDIT_QUERY_EXECUTEDevent 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 thePiiHashernamed-interface and the catalog enum. - Deploy path: Flyway runs under
papillon_audit_admincredentials 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 atTenantOnboardedreception (section 6.4). - The salt is never persisted in cleartext, never logged, never included in any error envelope, never included in any export.
- The
PiiHasherbean is the only callable surface. Producers consumeString hash(String value)and get back a hex string. - Hash algorithm: SHA-256, input format
salt_bytes || UTF-8(canonical_form(value)).canonical_formstrips 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_logis never rolled back schema-wise once data lands (CIMA + ARTCI retention).- Adding new columns is permitted (V1 may add e.g. a
purge_atdiscriminator 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-schemalabel 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
SimpleMeterRegistryfor 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¶
- Read
docs/prd/audit-log-prd.md(PRD, ground truth). - Read this architecture document (you are here).
- Skim
docs/kb/audit-salt-storage-options.md(background on the salt-store decision). - Confirm
AUDIT_KEKis set in the local.env(generate viaopenssl rand -hex 32; never commit). - Run the smoke command above; verify green.
- Read the story file at
docs/stories/audit-log/AUDIT-NNN.mdfor 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/.