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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Artha.Compliance.AML" Version="0.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Artha.Compliance.AML" Version="0.2.1" />
                    
Directory.Packages.props
<PackageReference Include="Artha.Compliance.AML" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Artha.Compliance.AML --version 0.2.1
                    
#r "nuget: Artha.Compliance.AML, 0.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Artha.Compliance.AML@0.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Artha.Compliance.AML&version=0.2.1
                    
Install as a Cake Addin
#tool nuget:?package=Artha.Compliance.AML&version=0.2.1
                    
Install as a Cake Tool

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 IComplianceService wherever you need a compliance decision.
  • You call GetDecisionAsync(...) with a canonical DecisionRequest.
  • 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.ConfigurationExtensions
  • Microsoft.Extensions.Logging.Abstractions
  • Microsoft.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
}

RawProviderPayload is 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 *AlertAsync methods on the Marble provider throw a clearly-worded ProviderRequestException telling 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:

  1. appsettings.json — placeholders, never real secrets
  2. appsettings.{Environment}.json — environment overrides
  3. User Secrets (development) — dotnet user-secrets set "Compliance:Providers:Marble:ApiKey" "…"
  4. Environment variables — Compliance__Providers__Marble__ApiKey=…
  5. 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 — Metadata is now IReadOnlyDictionary<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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.1 133 5/14/2026
0.2.0 106 5/14/2026
0.1.0 111 5/14/2026

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).