Aller au contenu

Story AUDIT-001b: Outbox consumer, AuditEventBuilder, catalog enum, and producer stubs

Module: audit-log Slice: AUDIT-001 (Architecture §11, split part b of a/b/c) Side: [BACKEND] Version target: V0.0.0 Priority: 3 Depends on: AUDIT-001a (schema + roles exist), AUDIT-000 (dev env). TENANT-001 + AUTH-001 (so TENANT/AUTH producers can be stubbed against real types) Can develop concurrently with: AUDIT-001c (PiiHasher) — they touch different files; both merge after 001a Merge order: After AUDIT-001a Estimated complexity: L PRD User Stories: AUDIT-US-01 (guaranteed audit on committed business actions) Wireframe: N/A — backend only


Objective

Build the write path of the audit log: the transactional-outbox consumer that drains event_publication (spring-modulith-events-jdbc) and INSERTs rows into audit_log, the AuditEventBuilder that producers call, and the AuditEventType enum that lists every catalog event from PRD §B.1 so producer modules can compile against the constants even before they emit for real. Producer "stubs" are wired for all event families (TENANT, AUTH, ING, OP, CP, PAY, NOTIF) so that turning on real emission later (AUDIT-002 onward) never needs a schema or contract change. This story delivers the durability + latency + freshness contract of AUDIT-US-01.


Backend Scope

Entities

None new — writes into audit_log (created in AUDIT-001a) and reads/updates event_publication (owned by the spring-modulith-events-jdbc library, not by AUDIT).

Migrations

None new. (event_publication is managed by the spring-modulith library; the audit_log schema is already frozen by AUDIT-001a.)

Service Layer

All under root package com.altarys.papillon.pcs.controlplane.audit: - AuditEventType (enum, public api package) — one entry for every event_type in PRD §B.1 (TENANT_, AGENCY_DISABLED, AUTH_, ING_, OP_, CP_, NOTIF_, PAY_, PLATFORM_ADMIN_, AUDIT_ self-events, AUDIT_SCHEMA_MIGRATED). Catalog strings stay UPPER_SNAKE_CASE. This enum is the FR-01 catalog gate: a producer can only name an event that exists here, and the writer rejects anything not in it. - AuditEvent (record / sealed type) — the immutable event a producer hands to the builder. Carries all §B.5 fields a producer can know at commit time. - AuditEventBuilder — the producer-facing API (named-interface, Modulith-exposed). fromCurrentContext() snapshots tenant_id, agency_id, country_code from the current TenantContext at build time. Caps payload at 32 KB and truncates user_agent to 500 chars (Risk R5). The sensitive-field setters accept only a Hashed<...> type (produced by PiiHasher in AUDIT-001c), so a raw PII string cannot structurally be placed in a hashed field. - AuditOutboxListener (...audit.internal) — @ApplicationModuleListener that runs post-commit, in-JVM, async. Drains an event_publication row, INSERTs into audit_log via papillon_app, then marks the publication complete. Catches a unique-constraint violation on (producer_module, producer_outbox_id) and treats it as a benign duplicate (FR-06), marking the publication complete without inserting twice. - Domain events: producers publish past-tense, no-Event-suffix domain events (per CLAUDE.md Java conventions) in the same transaction as their business write; AUDIT's listener consumes them. - Producer stubs: a thin emit helper per producer family so TENANT/AUTH/ING/OP/CP/PAY/NOTIF can reference the catalog constants and the builder now. Real emission lands progressively (AUDIT-002 turns on PAY + NOTIF). A stub compiles and is wired but is a no-op until its module activates. - spring-modulith republisher* configured at AUDIT_OUTBOX_POLL_INTERVAL_MS (default 30 000 ms), exponential backoff capped at 5 min (Architecture §5.2).

API Endpoints

None (write path is in-process Spring Modulith calls only; no external HTTP write surface).

Validation Rules

  • FR-01 (catalog gate): an event_type not present in AuditEventType is rejected at the AUDIT write boundary with a structured error — never silently dropped. The producer's failure path runs.
  • FR-02 (mandatory fields): any field marked Y for the event family in PRD §B.3 that is missing/null at write time is rejected at the boundary; the producer fails closed.
  • FR-06 (idempotence): duplicate (producer_module, producer_outbox_id) never produces a second audit_log row.
  • payload ≤ 32 KB; user_agent ≤ 500 chars.

Multi-Tenant Considerations

  • The event is sealed at producer-commit: AuditEventBuilder.fromCurrentContext() snapshots tenant_id, agency_id, country_code from the producer's TenantContext. The listener does not re-resolve tenant at drain time — it writes the snapshotted values, so a row is correct even if the tenant context changed by the time the listener runs.
  • Audit rows for every tenant — SHARED and BYO alike — are written to the platform DB by papillon_app (AUDIT-ADR-01). The producer's business write may live in a BYO tenant DB, but the outbox row and the audit row are on the platform DB.

Audit & Logging

This story IS the audit write path. It does not itself emit catalog events except indirectly: the AUDIT_RECONCILED self-event (drain-complete) is emitted in AUDIT-003 (v0.1.0); the republisher/drain mechanism it depends on is wired here.


Frontend Scope

N/A — backend only.


Acceptance Criteria

AC-01.1: Zero-loss on committed actions
Given a producer commits a business write whose event is in the §B.1 catalog
When the JVM crashes (or the AUDIT store is briefly unreachable) immediately after the commit
Then on recovery the audit entry is persisted no later than freshness_p95 + 60s
And no committed business action ever exists without its audit entry

AC-01.2: Producer latency budget
Given any producer endpoint that records an audit entry on its happy path
When it responds
Then the added p95 server-side latency attributable to audit recording is <= 20 ms at the producer's HTTP boundary

AC-01.3: Audit failure does not roll back business
Given AUDIT ingest is degraded
When a producer cannot durably persist its audit entry within budget
Then the business action still commits, the entry is queued for reconciliation, and no business action is failed because of an audit-side problem

Compliance Rules

  • CIMA Reg. 01-24 §16.5 — AUDIT-US-01 zero-loss is what lets the platform claim the audit log is a complete proof base. Status VÉRIFIÉ for the minimal-field requirement.
  • The outbox guarantee (business write + outbox row in one transaction) is the architectural anchor (D4 + D7) that makes "every committed business action has an audit entry" automatically true — an uncommitted business write produces no audit obligation.

Standards & Conventions

  • docs/standards/java-spring-guidelines.md — Spring Modulith, @ApplicationModuleListener, named-interfaces, Spring Data JDBC, domain-event naming (past tense, no Event suffix).
  • docs/standards/multi-datasource-patterns.md — platform-DB write path for all tenants.
  • docs/standards/critical-rules.md — rule 1 (tenant scoping), rule 3 (catalog gate).
  • docs/standards/tech-stack.md — spring-modulith-events-jdbc as the locked outbox.

Testing Requirements

Unit Tests

  • AuditEventBuilder rejects an unknown event_type (FR-01).
  • Builder rejects a missing mandatory field per §B.3 family (FR-02).
  • Builder caps payload at 32 KB and truncates user_agent at 500 chars.

Integration Tests

  • AC-01.1 zero-loss: kill the JVM after the business INSERT but before the AUDIT INSERT; on restart, the audit row appears within the freshness budget with the original business_committed_at and a later audit_persisted_at (Architecture §13.3).
  • AC-01.3: pause the audit-insert capability; assert the business action still commits and the entry is queued, then drains on resume.
  • FR-06: deliver the same outbox event twice; assert exactly one audit_log row.
  • Tenant isolation: commit business actions for tenant A and tenant B; assert each audit row carries the correct snapshotted tenant_id (Architecture §13.2).

What QA Will Validate

  • A committed business action always yields exactly one audit row (no loss, no duplicate).
  • Producer latency stays within 20 ms p95.
  • The catalog enum lists every §B.1 event_type; unknown types are rejected.

Out of Scope

  • PiiHasher + salt provisioning (AUDIT-001c) — the builder's Hashed<...> field type is referenced here but the hashing implementation is delivered there.
  • Real emission on any producer (AUDIT-002 turns on PAY + NOTIF; other modules activate in their own stories). Stubs only here.
  • AUDIT_RECONCILED self-event + ingest-degradation metrics (AUDIT-003).
  • Query / by-case / export APIs (V1).
  • Defensive PII scan at the AUDIT boundary (V1, AUDIT-008).

Definition of Done

  • [ ] AuditOutboxListener drains event_publication and INSERTs into audit_log post-commit
  • [ ] AuditEventType enum lists every PRD §B.1 event_type; unknown types rejected (FR-01)
  • [ ] AuditEventBuilder snapshots tenant context, enforces §B.3 mandatory fields (FR-02), caps payload/user_agent
  • [ ] AC-01.1 zero-loss verified by a JVM-crash integration test
  • [ ] AC-01.2 producer latency <= 20 ms p95 verified
  • [ ] AC-01.3 business commit never rolled back for audit failure
  • [ ] FR-06 idempotence verified (duplicate outbox event → one row)
  • [ ] Tenant isolation verified (snapshotted tenant_id; platform DB for both V1 profiles)
  • [ ] Audit write path produces an entry for every committed catalog event (no silent failure on a store outage — entries queue and drain)