diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/AmlCopilot.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/AmlCopilot.kt new file mode 100644 index 0000000..44454ed --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/AmlCopilot.kt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.copilot + +/** + * Plug-in port for an LLM-backed assistant that helps a human reviewer work an AML case. + * + * Implementations are provided out of tree (for example an LLM-backed adapter) and are NOT part of this open-source + * service. The contract is deliberately generative and makes no determinism or correctness promise: the returned + * [CopilotResponse] is ADVISORY guidance the reviewer weighs, never an authoritative decision. This port performs no + * persistence and encodes no business prompt. A technical or transient failure is thrown as an [AmlCopilotException]. + */ +interface AmlCopilot { + fun assist(request: CopilotRequest): CopilotResponse +} diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/AmlCopilotException.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/AmlCopilotException.kt new file mode 100644 index 0000000..45dda36 --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/AmlCopilotException.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.copilot + +/** Signals a technical or transient failure producing copilot assistance, distinct from advisory output. */ +class AmlCopilotException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/CopilotRequest.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/CopilotRequest.kt new file mode 100644 index 0000000..16b3e17 --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/CopilotRequest.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.copilot + +import com.fincore.compliance.domain.KycSession + +/** + * Context for an [AmlCopilot] assist call. [caseReference] is an opaque token (bounded by the shared + * KycSession.MAX_SUBJECT_REFERENCE_LENGTH). [context] is a list of generic notes or codes, never raw PII or business + * rules; the adapter resolves any further detail out of tree. + */ +data class CopilotRequest( + val caseReference: String, + val context: List = emptyList(), +) { + init { + require(caseReference.isNotBlank() && caseReference.length <= KycSession.MAX_SUBJECT_REFERENCE_LENGTH) { + "caseReference must be non-blank and at most ${KycSession.MAX_SUBJECT_REFERENCE_LENGTH} characters" + } + } +} diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/CopilotResponse.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/CopilotResponse.kt new file mode 100644 index 0000000..386582f --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/copilot/CopilotResponse.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.copilot + +/** + * Advisory output of an [AmlCopilot] assist call. [summary] and [recommendations] are non-authoritative guidance for + * the human reviewer and carry no PII. + */ +data class CopilotResponse( + val summary: String, + val recommendations: List = emptyList(), +) diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/AutomatedSuspensionException.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/AutomatedSuspensionException.kt new file mode 100644 index 0000000..a00bdfa --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/AutomatedSuspensionException.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.suspension + +/** Signals a technical or transient failure performing an automated suspension, distinct from a business outcome. */ +class AutomatedSuspensionException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/AutomatedSuspensionPort.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/AutomatedSuspensionPort.kt new file mode 100644 index 0000000..afb8cb8 --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/AutomatedSuspensionPort.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.suspension + +/** + * Plug-in port for an automated suspension action triggered by a caller-decided signal. + * + * The port encodes NO rule for what the signal is; the caller decides when to suspend. Implementations are provided + * out of tree and are NOT part of this open-source service; the port performs no persistence. A business outcome is + * returned as a [SuspensionResult]; a technical or transient failure is thrown as an [AutomatedSuspensionException]. + */ +interface AutomatedSuspensionPort { + fun requestSuspension(request: SuspensionRequest): SuspensionResult +} diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/SuspensionRequest.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/SuspensionRequest.kt new file mode 100644 index 0000000..609ad00 --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/SuspensionRequest.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.suspension + +import com.fincore.compliance.domain.KycSession + +/** + * A request to suspend a subject. [subjectReference] is an opaque token (bounded by the shared + * KycSession.MAX_SUBJECT_REFERENCE_LENGTH); [reason] is a generic, non-PII reason code or note. + */ +data class SuspensionRequest( + val subjectReference: String, + val reason: String, +) { + init { + require(subjectReference.isNotBlank() && subjectReference.length <= KycSession.MAX_SUBJECT_REFERENCE_LENGTH) { + "subjectReference must be non-blank and at most ${KycSession.MAX_SUBJECT_REFERENCE_LENGTH} characters" + } + require(reason.isNotBlank() && reason.length <= MAX_REASON_LENGTH) { + "reason must be non-blank and at most $MAX_REASON_LENGTH characters" + } + } + + companion object { + const val MAX_REASON_LENGTH = 280 + } +} diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/SuspensionResult.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/SuspensionResult.kt new file mode 100644 index 0000000..a901282 --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/suspension/SuspensionResult.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.suspension + +/** + * Outcome of an [AutomatedSuspensionPort] action: a business outcome, distinct from a technical failure (a thrown + * [AutomatedSuspensionException]). + * + * [AlreadySuspended] is a first-class outcome so a replayed signal is unambiguous (the port is idempotent). + * [Rejected.reason] is a generic, provider-reported business refusal, never a technical error. + */ +sealed interface SuspensionResult { + data class Suspended( + val providerReference: String, + ) : SuspensionResult + + data object AlreadySuspended : SuspensionResult + + data class Rejected( + val reason: String, + ) : SuspensionResult +} diff --git a/services/compliance/src/test/kotlin/com/fincore/compliance/application/copilot/AmlCopilotContractTest.kt b/services/compliance/src/test/kotlin/com/fincore/compliance/application/copilot/AmlCopilotContractTest.kt new file mode 100644 index 0000000..6234d43 --- /dev/null +++ b/services/compliance/src/test/kotlin/com/fincore/compliance/application/copilot/AmlCopilotContractTest.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.copilot + +import com.fincore.compliance.domain.KycSession +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.Test + +private class FixedAmlCopilot( + private val response: CopilotResponse, +) : AmlCopilot { + override fun assist(request: CopilotRequest): CopilotResponse = response +} + +class AmlCopilotContractTest { + @Test + fun `should expose a single assist method when inspected`() { + AmlCopilot::class.java.isInterface shouldBe true + + val method = + AmlCopilot::class.java.declaredMethods + .filter { !it.isBridge && !it.isSynthetic } + .single { it.name == "assist" } + + method.returnType shouldBe CopilotResponse::class.java + method.parameterTypes.toList() shouldBe listOf(CopilotRequest::class.java) + } + + @Test + fun `should return the advisory response from the implementation`() { + val provider = FixedAmlCopilot(CopilotResponse("summary", listOf("review-id-docs"))) + + val result = provider.assist(CopilotRequest("case-1", listOf("note-a"))) + + result.shouldBeInstanceOf().recommendations shouldBe listOf("review-id-docs") + } + + @Test + fun `should reject a blank case reference`() { + shouldThrow { CopilotRequest(" ") } + } + + @Test + fun `should reject a case reference over the length limit`() { + shouldThrow { CopilotRequest("x".repeat(KycSession.MAX_SUBJECT_REFERENCE_LENGTH + 1)) } + } +} diff --git a/services/compliance/src/test/kotlin/com/fincore/compliance/application/suspension/AutomatedSuspensionPortContractTest.kt b/services/compliance/src/test/kotlin/com/fincore/compliance/application/suspension/AutomatedSuspensionPortContractTest.kt new file mode 100644 index 0000000..2e3444e --- /dev/null +++ b/services/compliance/src/test/kotlin/com/fincore/compliance/application/suspension/AutomatedSuspensionPortContractTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.suspension + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.Test + +private class FixedAutomatedSuspensionPort( + private val result: SuspensionResult, +) : AutomatedSuspensionPort { + override fun requestSuspension(request: SuspensionRequest): SuspensionResult = result +} + +class AutomatedSuspensionPortContractTest { + private val request = SuspensionRequest("subject-1", "signal-a") + + @Test + fun `should expose a single request suspension method when inspected`() { + AutomatedSuspensionPort::class.java.isInterface shouldBe true + + val method = + AutomatedSuspensionPort::class.java.declaredMethods + .filter { !it.isBridge && !it.isSynthetic } + .single { it.name == "requestSuspension" } + + method.returnType shouldBe SuspensionResult::class.java + method.parameterTypes.toList() shouldBe listOf(SuspensionRequest::class.java) + } + + @Test + fun `should return suspended when the implementation suspends`() { + FixedAutomatedSuspensionPort(SuspensionResult.Suspended("ref-1")) + .requestSuspension(request) + .shouldBeInstanceOf() + } + + @Test + fun `should return already suspended for a replayed signal`() { + FixedAutomatedSuspensionPort(SuspensionResult.AlreadySuspended) + .requestSuspension(request) + .shouldBeInstanceOf() + } + + @Test + fun `should return rejected when the implementation refuses`() { + FixedAutomatedSuspensionPort(SuspensionResult.Rejected("not eligible")) + .requestSuspension(request) + .shouldBeInstanceOf() + } + + @Test + fun `should reject a blank subject reference`() { + shouldThrow { SuspensionRequest(" ", "signal-a") } + } + + @Test + fun `should reject a blank reason`() { + shouldThrow { SuspensionRequest("subject-1", " ") } + } + + @Test + fun `should reject a reason over the length limit`() { + shouldThrow { + SuspensionRequest("subject-1", "a".repeat(SuspensionRequest.MAX_REASON_LENGTH + 1)) + } + } +}