An AML (Anti-Money Laundering) compliance toolkit written in the
Capa language.
Reads a JSONL transaction log, runs four detection rules
(threshold, watchlist, structuring, velocity), aggregates per
day and per account, and emits four reports (CSV aggregates,
CSV account summary, full JSON detail, human-readable
alerts.txt).
Why this exists. Capa is a capability-typed language. Toy
examples do not exercise enough surface to convince a
compliance team that the capability story holds at real-program
scale. This project is a multi-module program that touches
every part of Capa: built-in capabilities (Fs, Stdio, Env,
Clock), capability attenuation (read-only Fs for inputs,
write-only Fs for outputs), three of the seed Capa libraries
(capa_cli, capa_datetime, capa_log), user-defined
capabilities (Logger), sum types with payloads, pattern
matching, the ? operator, the manifest emitter.
A compliance team needs daily audit reports off a stream of financial transactions. Each line of the input is a transaction; four detection rules run against the list, every flag is tied back to a named rule, and the outputs go to a fixed directory for the auditor's morning review.
| Rule | Fires when | Risk |
|---|---|---|
threshold |
Amount >= 10000 | Critical |
threshold |
Amount >= 1000 | High |
watchlist |
Destination account matches a prefix from the watchlist file (or built-in default) | Critical |
structuring |
>= 3 transactions from the same source on the same day, each under 10000, totalling >= 10000 | Critical |
velocity |
Source account peaks at >= 5 transactions in a day while the second-busiest day had <= 2 | High |
A transaction may be hit by several rules; the final
risk is the strongest verdict and findings[] lists every
rule that fired with its own reason. A transaction that no rule
flagged is classified by amount alone (Low if < 100, Medium
otherwise) and contributes to volume aggregates but not to the
alerts file.
.
├── reporter.capa entry point, CLI, attenuated Fs split
├── domain.capa shared types (Transaction, Finding, ...)
├── parsing.capa JSONL -> Transaction
├── rules.capa the four rule functions (pure)
├── engine.capa run rules + merge findings
├── aggregation.capa daily + per-account rollups
├── sinks/
│ ├── csv_sink.capa aggregates + accounts CSV
│ ├── json_sink.capa full JSON detail
│ └── alerts_sink.capa human-readable alerts.txt
├── data/
│ ├── transactions.jsonl 37-transaction sample, 7 days
│ └── watchlist.txt prefix watchlist (# comments OK)
├── libraries/ vendored Capa seed libraries
│ ├── capa_cli/
│ ├── capa_datetime/
│ └── capa_log/
├── LICENSE MIT
└── README.md
Prerequisites: a Capa toolchain that includes the multi-module
loader fix and the -- arg pass-through (Capa >= 0.8.4, commit
4c4511b or newer).
From the repo root:
capa --run reporter.capa -- data/transactions.jsonl --watchlist data/watchlist.txtExpected output:
[INFO] reading data/transactions.jsonl
[INFO] parsed 37 transactions
[INFO] loaded 3 watchlist prefix(es) from data/watchlist.txt
[WARN] [threshold] tx-2026-010: amount 3200.0 >= 1000.0
[WARN] [structuring] tx-2026-010: structuring: 4 txs from 'acc-7777' on 2026-05-15 totalling 12600.0, each under threshold
... (15 flagged transactions total)
[INFO] wrote ./aggregates-2026-05-19.csv
[INFO] wrote ./accounts-2026-05-19.csv
[INFO] wrote ./report-2026-05-19.json
[INFO] wrote ./alerts-2026-05-19.txt
[WARN] 15 flagged transaction(s) need review
audit-trail-reporter: success
Usage: audit-trail-reporter [FLAGS] [OPTIONS] INPUT
Arguments:
INPUT path to a JSONL transaction log
(must live under data/, see Audit section)
Flags:
-v, --verbose enable DEBUG-level logging
Options:
-w, --watchlist STR path to watchlist file (one prefix per line)
-o, --output-dir STR directory for report files (default: cwd)
--min-amount STR drop transactions below this amount (default: 0)
-d, --date STR restrict to a single YYYY-MM-DD day (default: all)
Example invocations:
# Built-in default watchlist (matches "acc-WATCHLIST*").
capa --run reporter.capa -- data/transactions.jsonl
# Verbose run.
capa --run reporter.capa -- data/transactions.jsonl --verbose
# Single-day report.
capa --run reporter.capa -- data/transactions.jsonl --date 2026-05-17
# Material transactions only (>= 1000).
capa --run reporter.capa -- data/transactions.jsonl --min-amount 1000
# Write reports to a dedicated directory (must exist).
mkdir reports
capa --run reporter.capa -- data/transactions.jsonl --output-dir reportsThe same source builds and runs end-to-end under Capa's WebAssembly backend on the full 37-record dataset, with all four rules firing and the same 15 flagged transactions as the Python reference path:
capa --wasm --run reporter.capa -- data/transactions.jsonlThe bundled data/tiny.jsonl (two records) is a faster smoke
test for the Wasm path.
The same source also builds as a Component Model .wasm
artifact with the canonical-ABI lowering for every imported
capability method (Stdio, Fs, Env, Clock):
capa --wasm --component --output reporter.wasm reporter.capaThe resulting .wasm is consumable by any Component-Model-
aware runtime and ships the WIT spec embedded in the binary.
And the artifact runs end-to-end under an external Component
Model runtime via capa --wasm --component --run, with the
same 15 flagged transactions as the Python reference path.
JSON is parsed entirely inside the guest module (Capa-source
parser bundled into the artifact), so no capa:host/json
import is emitted:
capa --wasm --component --run reporter.capa -- data/transactions.jsonlThe same source also builds as a stock WASI Preview 2 component that
runs on any WASI runtime without the Capa host. Unlike a program whose
paths are compile-time literals, this is a CLI tool: its input,
watchlist, and output-dir paths come from env.args(), so the operator
must declare the filesystem authority the component may use, via
--preopen.
Which argv arguments actually reach the filesystem is a compiler-derived, by-construction fact you can print without running the program:
capa --wasi-surface reporter.capareporter.capa: WASI path-arg surface (compiler-derived, by-construction):
argv[*] -> Fs.read (read-only)
argv[*] -> Fs.write (writes)
That proven argv-to-filesystem boundary is what tells the operator which
directories to declare: the read paths (INPUT, --watchlist) live
under data/, the writes go to --output-dir. A single preopen of the
repo root covers both:
mkdir -p out
capa --wasm --component --wasi --preopen . --run reporter.capa -- \
data/transactions.jsonl --watchlist data/watchlist.txt --output-dir outThis flags the same 15 transactions and writes the four reports into
out/, byte-for-byte identical to the Python reference path (modulo line
endings). Pass an explicit --output-dir (a subdirectory under the
preopen); the default output-dir resolves to a bare ./ prefix that the
WASI preopen resolver rejects.
The benefit: the component runs on any stock WASI runtime with no Capa
host, the filesystem authority is materialized by the runtime through
operator-declared preopens (not baked into the binary), and the
argv-to-filesystem boundary that tells you what to preopen is an
auditable compile-time fact rather than a claim in the docs. The
--wasi path is experimental and opt-in.
Run capa --manifest reporter.capa and the surface looks like:
USER-DEFINED CAPABILITIES: Logger (impl StdioLogger)
TOP-LEVEL FUNCTION CAPABILITIES:
threshold_rule pure
watchlist_rule pure
structuring_rule pure
velocity_rule pure
classify_all pure
merge_into_classified pure
apply_filters pure
daily_aggregates pure
account_summaries pure
render_aggregates_csv pure
render_accounts_csv pure
render_report_json pure
render_alerts pure
read_transactions [Fs]
load_watchlist [Fs]
write_csv [Fs]
write_json [Fs]
write_alerts [Fs]
run [Logger, Fs, Fs]
main [Stdio, Fs, Env, Clock]
Every rule and every aggregator is pure. They cannot read
the filesystem, mint a string and print it, or call out to a
clock. A diff that added a stray stdio.println inside
structuring_rule would fail to compile because the rule's
signature does not take stdio: Stdio.
The user-defined Logger capability adds a second layer: run
declares [Logger, Fs, Fs], not [Stdio, Fs, Fs]. The
Stdio is handed over to make_stdio_logger once in main
and never seen again past that boundary.
main acquires a single Fs from the runtime. Before anything
useful happens, it splits that one cap into two narrower ones:
let read_fs = fs.restrict_to("data/")
let write_fs = fs.restrict_to(opts.output_dir)
read_fsis passed toread_transactionsandload_watchlist. Any path the runtime'sFs.allowsdoes not see as starting with"data/"is rejected at runtime. The parser cannot reach/etc/passwdeven if it tried.write_fsis passed to the three sinks. They cannot read arbitrary files even though their declared cap is stillFs.
Two Fss in run's signature, with disjoint authority. The
audit manifest doesn't display the attenuation prefixes
(they're runtime data), but the code structure shows that no
sink ever sees the read Fs, and no parser ever sees the write
Fs.
- Multi-module project: 9
.capafiles including asinks/subdirectory, all linked transitively viaimport. Demonstrates Capa's resolution order (importer-local, CAPA_PATH, ./libraries, project root). - All four built-in caps:
Fs(input + watchlist read, four file writes),Stdio(CLI errors),Env(CLI args),Clock(report timestamp). - User-defined
Loggercapability via capa_log, with a cap-bearingStdioLoggerimpl holding the realStdio. - Capability attenuation:
Fs.restrict_tosplits authority across the read and write halves of the pipeline. - Sum types with + without payloads:
ReportError = JsonError(String) | IoFailed(String) | BadArgs(String)RiskCategory = Low | Medium | High | Critical
- The
?operator for error propagation, used throughoutparsing.capa,load_watchlist, and every sink writer. - Nested variant patterns (
Err(HelpRequested) -> ...). - UPPERCASE constants (
CRITICAL_AMOUNT,DEBUG,INFO, ...) that previously crashed the transpiler; this program is a regression demo for that fix as well. - String interpolation,
Map,List,Option,Resultmethod surfaces, plusparse_json/to_json. - Three seed libraries, vendored into
libraries/:capa_clifor argument parsingcapa_datetimefor ISO 8601 timestamp parsing + date formattingcapa_logfor levelled logging
This is a demo, not a production deployment. v0.2 ships deliberately tight:
- No streaming. The whole file is read into memory before
parsing. For million-line logs a future iteration would
switch
Fs.readfor a line iterator. - No HTTP enrichment. Watchlist comes from a file, not a
regulator's API. Swapping in
capa_http(vendored already in the language repo) is a 30-line diff: replaceload_watchlistwith a call into theHttpcap. - Single-currency. The
currencyfield is preserved in the output but no FX conversion happens; all amounts compare numerically. A v3 iteration would add aRatescapability for daily exchange rates. - Watchlist is exact-prefix match. No regex, no fuzzy matching, no Unicode normalisation.
- TOCTOU race on
Fs.restrict_to. The Capa runtime canonicalises paths throughos.path.realpath(resolves..and symlinks) before comparing, sodata/../etc/passwdand a symlink pointing outsidedata/are both denied. A symlink swap between theallows()check and the underlyingopen()can still be exploited in a hostile setting; fully closing that gap requires open-at-dirfd semantics.
MIT. See LICENSE.