Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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<String> = 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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> = emptyList(),
)
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<CopilotResponse>().recommendations shouldBe listOf("review-id-docs")
}

@Test
fun `should reject a blank case reference`() {
shouldThrow<IllegalArgumentException> { CopilotRequest(" ") }
}

@Test
fun `should reject a case reference over the length limit`() {
shouldThrow<IllegalArgumentException> { CopilotRequest("x".repeat(KycSession.MAX_SUBJECT_REFERENCE_LENGTH + 1)) }
}
}
Original file line number Diff line number Diff line change
@@ -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<SuspensionResult.Suspended>()
}

@Test
fun `should return already suspended for a replayed signal`() {
FixedAutomatedSuspensionPort(SuspensionResult.AlreadySuspended)
.requestSuspension(request)
.shouldBeInstanceOf<SuspensionResult.AlreadySuspended>()
}

@Test
fun `should return rejected when the implementation refuses`() {
FixedAutomatedSuspensionPort(SuspensionResult.Rejected("not eligible"))
.requestSuspension(request)
.shouldBeInstanceOf<SuspensionResult.Rejected>()
}

@Test
fun `should reject a blank subject reference`() {
shouldThrow<IllegalArgumentException> { SuspensionRequest(" ", "signal-a") }
}

@Test
fun `should reject a blank reason`() {
shouldThrow<IllegalArgumentException> { SuspensionRequest("subject-1", " ") }
}

@Test
fun `should reject a reason over the length limit`() {
shouldThrow<IllegalArgumentException> {
SuspensionRequest("subject-1", "a".repeat(SuspensionRequest.MAX_REASON_LENGTH + 1))
}
}
}
Loading