From b4d0ee19bb3853d7ea96734e5a6a9ee09501b9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Sat, 13 Jun 2026 09:58:42 +0200 Subject: [PATCH] feat(audit): implement correlation ID handling for auditable events and commands --- build.sbt | 2 +- .../persistence/message/package.scala | 43 +++++++++++++++++++ .../persistence/typed/EntityBehavior.scala | 11 +++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 74a157d..7611c3a 100644 --- a/build.sbt +++ b/build.sbt @@ -16,7 +16,7 @@ ThisBuild / organization := "app.softnetwork" name := "generic-persistence-api" -ThisBuild / version := "0.8.6.2" +ThisBuild / version := "0.9-SNAPSHOT" lazy val moduleSettings = Seq( crossScalaVersions := Seq(scala212, scala213), diff --git a/core/src/main/scala/app/softnetwork/persistence/message/package.scala b/core/src/main/scala/app/softnetwork/persistence/message/package.scala index 4344533..7816966 100644 --- a/core/src/main/scala/app/softnetwork/persistence/message/package.scala +++ b/core/src/main/scala/app/softnetwork/persistence/message/package.scala @@ -123,4 +123,47 @@ package object message { /** Cbor events Marker trait for serializing an event using Jackson CBOR Serializer */ trait CborEvent extends Event + + /** Correlation/audit capability shared by commands and events (Story 13.7 — cross-service audit + * trail). `correlationId` is abstract so each side backs it with the storage matching its + * lifecycle: + * - commands are plain case classes, transient, (Kryo-)serialized only in transit → a mutable + * `var` (no constructor churn; set once before dispatch). + * - events are immutable, journaled (ScalaPB) → backed by the generated proto `correlation_id` + * field, the durable hop that survives the journal + replay. + */ + trait Auditable { + + /** ABSTRACT — backed by a `var` (commands) or a generated proto field (events). */ + def correlationId: Option[String] + + /** True once a correlation id has been set/propagated. */ + def auditable: Boolean = correlationId.nonEmpty + + /** Returns a value carrying `correlationId` — in place for commands (`this`), or an immutable + * copy for ScalaPB events (the generated builder). + */ + def withCorrelationId(correlationId: String): Auditable + } + + /** Commands: the `var` adds NO constructor parameter to the case classes mixing it in, and + * `withCorrelationId` mutates in place + returns `this`, so the caller keeps the concrete + * command type for `!?`. Carried across the cluster-sharding boundary by the (chill/Kryo) + * FieldSerializer. + */ + trait AuditableCommand extends Command with Auditable { + + var correlationId: Option[String] = None + + override def withCorrelationId(correlationId: String): AuditableCommand = { + this.correlationId = Some(correlationId) + this + } + } + + /** Events: marker only — `correlationId` / `withCorrelationId` are SUPPLIED by ScalaPB from the + * `optional string correlation_id` field (wired via `option (scalapb.message).extends`), so the + * durable value lives in the immutable message (survives journal + replay), not in a `var`. + */ + trait AuditableEvent extends Event with Auditable } diff --git a/core/src/main/scala/app/softnetwork/persistence/typed/EntityBehavior.scala b/core/src/main/scala/app/softnetwork/persistence/typed/EntityBehavior.scala index eecf3f6..82dbe8a 100644 --- a/core/src/main/scala/app/softnetwork/persistence/typed/EntityBehavior.scala +++ b/core/src/main/scala/app/softnetwork/persistence/typed/EntityBehavior.scala @@ -55,6 +55,17 @@ trait EntityCommandHandler[C <: Command, S <: State, E <: Event, R <: CommandRes )(implicit context: ActorContext[C] ): Effect[E, Option[S]] + + /** Story 13.7 — stamp a correlation id onto an auditable event before it is persisted (the + * durable hop for the cross-service audit trail). The ScalaPB-generated `withCorrelationId` + * returns the concrete message type at runtime but is typed `Auditable` through the trait, so we + * re-narrow to `Evt`. No-op when no id has been propagated onto the command. + */ + protected def withCid[Evt <: app.softnetwork.persistence.message.AuditableEvent]( + event: Evt, + correlationId: Option[String] + ): Evt = + correlationId.fold(event)(cid => event.withCorrelationId(cid).asInstanceOf[Evt]) } trait EntityEventHandler[S <: State, E <: Event] {