Artha.Compliance.AML
0.2.1
dotnet add package Artha.Compliance.AML --version 0.2.1
NuGet\Install-Package Artha.Compliance.AML -Version 0.2.1
<PackageReference Include="Artha.Compliance.AML" Version="0.2.1" />
<PackageVersion Include="Artha.Compliance.AML" Version="0.2.1" />
<PackageReference Include="Artha.Compliance.AML" />
paket add Artha.Compliance.AML --version 0.2.1
#r "nuget: Artha.Compliance.AML, 0.2.1"
#:package Artha.Compliance.AML@0.2.1
#addin nuget:?package=Artha.Compliance.AML&version=0.2.1
#tool nuget:?package=Artha.Compliance.AML&version=0.2.1
Artha.Compliance.AML
A small, provider-agnostic compliance / AML client for .NET 8. Your code asks one question — "is this transaction OK to process?" — and the library routes the call to whichever external compliance provider (Marble, AMLBot, …) you've configured. No database, no persistence inside the library — your application owns its own outbox, audit log, Service Bus, etc.
Currently shipping in this package: Marble is wired live. AMLBot is scaffolded (HTTP wire format pending). Adding more providers is a small contract implementation — see Adding a provider below.
What you need to know in one minute
┌─────────────────┐
your code ── DecisionRequest ──► │ Marble │
┌──────►│ (api.checkmar… │
┌─────────────────┐ │ └─────────────────┘
│ IComplianceSvc │────┤
│ (selector) │ │ ┌─────────────────┐
└─────────────────┘ └──────►│ AMLBot │
your code ◄── DecisionResult ── │ (api.amlbot…) │
└─────────────────┘
- You inject
IComplianceServicewherever you need a compliance decision. - You call
GetDecisionAsync(...)with a canonicalDecisionRequest. - You get back a canonical
DecisionResult(Approve/Review/Reject/Pending) plus the rule hits and a provider reference. - The library throws typed exceptions (
ComplianceValidationException,ProviderUnavailableException, …) — your global exception handler maps them to HTTP responses.
Install
dotnet add package Artha.Compliance.AML
Target framework: net8.0.
The package brings these transitive dependencies:
Microsoft.Extensions.Http+Microsoft.Extensions.Http.Resilience(HTTP client factory + Polly-based retries / circuit breaker)Microsoft.Extensions.Options.ConfigurationExtensionsMicrosoft.Extensions.Logging.AbstractionsMicrosoft.Extensions.DependencyInjection.Abstractions
No ASP.NET dependency — the library works inside a console app, a worker service, a Web API, anything that hosts IServiceCollection.
5-step integration (fresher-friendly)
Step 1 — Add the package
dotnet add package Artha.Compliance.AML --version 0.2.1
Step 2 — Add a Compliance section to appsettings.json
{
"Compliance": {
"ActiveProvider": "Marble",
"ProviderPreferenceOrder": [ "Marble" ],
"Providers": {
"Marble": {
"Enabled": true,
"BaseUrl": "https://api.checkmarble.com/v1",
"ApiKey": "<your-marble-bearer-token>",
"TimeoutSeconds": 30,
"AdditionalSettings": {
"TransactionTable": "transactions",
"CustomerTable": "customers"
}
}
}
}
}
Do not commit secrets. In dev use
dotnet user-secrets, in prod use Azure Key Vault / your secret manager. See Where do secrets come from? below.
Step 3 — Register the library in Program.cs
using Artha.Compliance.AML.Extensions;
var builder = WebApplication.CreateBuilder(args);
// All it takes:
builder.Services.AddCompliance(builder.Configuration);
var app = builder.Build();
// ...
AddCompliance binds the Compliance configuration section, validates it at startup (fail-fast if no provider is enabled), and registers IComplianceService plus one named HttpClient per provider with standard resilience defaults (retries + circuit breaker via Microsoft.Extensions.Http.Resilience).
Step 4 — Inject IComplianceService where you need it
using Artha.Compliance.AML.Abstractions;
using Artha.Compliance.AML.Models;
public class PayinService
{
private readonly IComplianceService _compliance;
public PayinService(IComplianceService compliance)
{
_compliance = compliance;
}
// ...
}
Step 5 — Ask for a decision before you commit the transaction
public async Task<bool> ProcessAsync(MyTransaction tx, CancellationToken ct)
{
var request = new DecisionRequest
{
ExternalReference = tx.Id.ToString(), // idempotency anchor (your tx id)
Subject = new Subject
{
Type = SubjectType.Individual,
ExternalId = tx.CustomerId.ToString(),
FullName = tx.CustomerName,
CountryCode = tx.CustomerCountry
},
Transaction = new TransactionInfo
{
Amount = tx.Amount,
Currency = tx.Currency,
Direction = TransactionDirection.Payin,
DestinationAccount = tx.DestinationWallet,
// Free-form passthrough for provider-specific rule fields:
Metadata = new Dictionary<string, object?>
{
["transaction_type"] = "Deposit",
["transaction_subtype"] = "Fiat",
["payment_method"] = "CASH"
}
}
};
DecisionResult result = await _compliance.GetDecisionAsync(request, ct);
return result.Status switch
{
ComplianceDecisionStatus.Approve => true,
ComplianceDecisionStatus.Review => HandleManualReview(tx, result),
ComplianceDecisionStatus.Reject => Block(tx, result),
ComplianceDecisionStatus.Pending => Park(tx, result), // provider is async / webhook to follow
_ => Block(tx, result)
};
}
That's it. The five steps above are everything you need for the happy path.
What's in a DecisionResult
public sealed record DecisionResult
{
public ComplianceDecisionStatus Status; // Approve / Review / Reject / Pending
public ProviderReference ProviderReference; // provider code + provider's decision id
public decimal? Score; // numeric risk score (provider-defined)
public IReadOnlyList<string> Reasons; // rule-hit names ("Sanctions hit", "Velocity", …)
public string? Summary; // human-readable summary if the provider gave one
public DateTimeOffset DecidedAt;
public string? RawProviderPayload; // full provider JSON — for YOUR audit log
}
RawProviderPayloadis intentionally exposed so your application can persist the raw response to your audit table. The library itself never writes to a database.
The canonical models (cheat sheet)
| Type | Purpose |
|---|---|
Subject |
Person or business being screened (individual / business / merchant). |
TransactionInfo |
The transaction details — amount, currency, direction, source / destination, free-form metadata. |
DecisionRequest |
What you send to GetDecisionAsync. Carries Subject + Transaction + optional Counterparty + Metadata. |
DecisionResult |
What you get back. Canonical outcome + score + reasons. |
Alert / Case |
Records on the provider side. Each carries a ProviderReference (code + provider id) so you can route updates back. |
ProviderReference |
{ ProviderCode, ProviderId }. Persist it on your side; pass it back on every follow-up CRUD. |
All Metadata dictionaries are IReadOnlyDictionary<string, object?> so numeric and boolean values serialise as JSON numbers / booleans (important for provider rules that compare >= 75 or = true).
Alerts and Cases (CRUD)
// Create a case (Marble: requires a CaseInboxId in AdditionalSettings)
var caseRecord = await _compliance.CreateCaseAsync(new CreateCaseRequest
{
ProviderCode = "Marble",
ExternalReference = tx.Id.ToString(),
Title = "High-risk transfer review",
LinkedAlerts = new[] { decision.ProviderReference } // attach the decision
});
// Later — close it
await _compliance.CloseCaseAsync(caseRecord.ProviderReference, CaseResolution.Cleared, "False positive");
Alerts and cases are provider-bound: the ProviderReference you pass in determines which provider gets called. The library does not fall back to another provider for CRUD (the alert / case lives in exactly one provider's database).
Marble note: Marble has no standalone Alerts API — rule hits are returned inside each Decision (see
DecisionResult.Reasons). The*AlertAsyncmethods on the Marble provider throw a clearly-wordedProviderRequestExceptiontelling you to use Cases instead.
Exceptions cheat-sheet
The library throws typed exceptions. Your global exception handler is expected to map them to HTTP responses. The classes live in Artha.Compliance.AML.Exceptions.
| Exception | When | Suggested HTTP |
|---|---|---|
ComplianceValidationException |
Your input was invalid before the provider was called | 422 |
ComplianceNotFoundException |
Resource (alert / case / subject) not found at provider | 404 |
ProviderUnavailableException |
Provider 5xx / timeout / circuit-open. Library already retried + tried the fallback chain. | 502 |
ProviderRequestException |
Provider returned 4xx for this specific call | 400 / 403 / etc. (carries StatusCode) |
ProviderConfigurationException |
Library was called with a provider code that isn't enabled / configured | 500 (deploy bug) |
ComplianceException |
Base class — catch-all if you don't want to enumerate | — |
Example mapping in a global handler:
catch (ComplianceValidationException ex)
{
return Problem(statusCode: 422, title: "Validation error", detail: ex.Message);
}
catch (ProviderUnavailableException ex)
{
return Problem(statusCode: 502, title: $"Provider {ex.ProviderCode} unavailable", detail: ex.Message);
}
catch (ComplianceException ex) // catch-all
{
return Problem(statusCode: 500, title: "Compliance error", detail: ex.Message);
}
Configuration reference
| Key | Required | Default | Meaning |
|---|---|---|---|
Compliance:ActiveProvider |
yes* | — | Provider code tried first for decisions |
Compliance:ProviderPreferenceOrder |
yes* | [] |
Ordered fallback chain on ProviderUnavailableException |
Compliance:Providers:<Code>:Enabled |
yes | false |
Provider participates only when true |
Compliance:Providers:<Code>:BaseUrl |
yes | — | Provider base URL |
Compliance:Providers:<Code>:ApiKey |
yes | — | Bearer token / API key |
Compliance:Providers:<Code>:TimeoutSeconds |
no | 30 |
Per-request timeout |
Compliance:Providers:<Code>:AdditionalSettings:<K> |
provider-specific | — | Free-form provider-specific options |
* You must set either ActiveProvider or at least one entry in ProviderPreferenceOrder; at least one provider must be Enabled = true. Validation runs at startup (ValidateOnStart).
Marble's AdditionalSettings
| Key | Default | Meaning |
|---|---|---|
TransactionTable |
transactions |
Marble data-model table receiving transactions for decisions |
CustomerTable |
customers |
Marble data-model table receiving subject ingest |
CaseInboxId |
— | Required only for CreateCaseAsync — Marble inbox id |
Where do secrets come from?
AddCompliance reads from the standard IConfiguration pipeline. You're free to layer providers however the host does it. Typical order, weakest to strongest:
appsettings.json— placeholders, never real secretsappsettings.{Environment}.json— environment overrides- User Secrets (development) —
dotnet user-secrets set "Compliance:Providers:Marble:ApiKey" "…" - Environment variables —
Compliance__Providers__Marble__ApiKey=… - Azure Key Vault (or your secret manager) — wins last
builder.Configuration
.AddJsonFile("appsettings.json")
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
.AddUserSecrets<Program>(optional: true)
.AddEnvironmentVariables()
.AddAzureKeyVault(new Uri(kvUrl), new DefaultAzureCredential());
builder.Services.AddCompliance(builder.Configuration);
Supported providers (v0.2.1)
| Provider | Code | Status | Notes |
|---|---|---|---|
| Marble | Marble |
✅ Live — Decisions, Subject ingest, Cases CRUD wired and verified | No Alerts API on Marble — use Cases. CaseInboxId required for CreateCaseAsync. |
| AMLBot | AMLBot |
⏸ Scaffolded — methods throw a clear ProviderRequestException until wire format is filled in |
Contributions welcome; same IComplianceProvider contract as Marble. |
Adding a provider (advanced)
internal sealed class MyProvider : IComplianceProvider
{
public const string Code = "MyProvider";
public string ProviderCode => Code;
// implement the rest of IComplianceProvider
}
// In your host:
builder.Services.AddCompliance(builder.Configuration);
builder.Services.AddComplianceProvider<MyProvider>("MyProvider",
applyAuth: (http, entry) =>
{
http.DefaultRequestHeaders.Add("X-Api-Key", entry.ApiKey);
});
Then add Compliance:Providers:MyProvider:{Enabled,BaseUrl,ApiKey} to config and you're done — the selector picks it up.
FAQ
Q: I called GetDecisionAsync with what I think is a risky transaction, but I got Approve. Is the library broken?
No — the library returns exactly what the provider returned. If a transaction shape doesn't match any published scenario / rule in your provider workspace, you'll get Approve with an empty Reasons list. Publish a matching scenario in the provider's UI and re-run.
Q: The provider went down. What happens?
The library throws ProviderUnavailableException after the standard resilience handler has retried + cooled the circuit breaker. If you've listed additional providers in ProviderPreferenceOrder, decisions will fall back through them automatically. Alert / Case CRUD does not fall back (the record lives in one specific provider).
Q: How do I get the raw provider JSON for audit?
DecisionResult.RawProviderPayload carries it. Persist it on your side.
Q: Can I pin a specific provider for one call?
Yes — set DecisionRequest.ProviderCodeOverride = "Marble". Fallback is disabled for that call.
Q: How do I test locally without making network calls?
Implement IComplianceProvider with a fake and register it via AddComplianceProvider<FakeProvider>("Fake"), then set ActiveProvider = "Fake" in test config.
Versioning
Pre-1.0. Minor bumps (0.x.0 → 0.(x+1).0) may contain breaking changes. Patches (0.x.y → 0.x.(y+1)) are docs / non-breaking fixes.
- 0.2.1 — README rewritten as a step-by-step integration guide; no binary change.
- 0.2.0 —
Metadatais nowIReadOnlyDictionary<string, object?>everywhere (was<string, string>in 0.1.0). Breaking. - 0.1.0 — Initial release.
Where the library lives in the architecture
┌─────────────────────────────────────────────────────────────────┐
│ Your application (Web API / worker) │
│ │
│ PayinService ───► IComplianceService.GetDecisionAsync(...) │
│ │ │
└───────────────────────────────────────┼─────────────────────────┘
│
┌─────────────────────▼──────────────────────┐
│ Artha.Compliance.AML │
│ │
│ ComplianceService ─► Selector ─► Marble / AMLBot / …
│ │
└────────────────────────────────────────────┘
│
▼
┌───────────────────────┐
│ Provider HTTP API │
│ (api.checkmarble…) │
└───────────────────────┘
Pure dependency-injection library — no hidden static state, no global config, no DB writes. Everything testable.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Http (>= 8.0.0)
- Microsoft.Extensions.Http.Resilience (>= 8.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
0.2.1 — README rewritten as a step-by-step integration guide (no binary change vs 0.2.0). 0.2.0 — Metadata changed from IReadOnlyDictionary<string,string> to IReadOnlyDictionary<string,object?> on DecisionRequest, TransactionInfo, Subject, Alert and Case so numeric / boolean rule fields serialise correctly (breaking change vs 0.1.0).