Snowflake CoCo CLI Hackathon 2026 (GCC Edition) — Problem Statement #1: "Build a copilot that surfaces risk and fraud signals and produces audit-ready regulatory outputs from natural language questions."
Team cypher: Sai Pranav · Udith
| What | An AML copilot that takes an analyst from a fraud signal to a documented finding and a cited, machine-checked SAR, entirely inside Snowflake |
| Built with | CoCo CLI (3 project skills), Snowpark, Cortex Search (hybrid: keyword + vector + reranker), Cortex Analyst + semantic view, Cortex Agent, AI_COMPLETE (claude-sonnet-4-5), Streamlit in Snowflake, RBAC + secure views |
| Detection | 5 deterministic rules mapped to numbered policy clauses; the LLM never decides what is fraud |
| Measured | On a held-out dataset nothing was tuned against: 4 of 5 rules catch every planted scheme; pass-through 10/13; precision 0.71–1.00 |
| Audit-ready | Append-only decisions by grant; every SAR's transaction citations machine-verified; auditors see PII masked, even inside AI answers |
| Tested | Two datasets (one held out), 14 end-to-end tests on live Snowflake, 36 / 36 governance checks, a full redeploy from scratch, rule tests that are proven to fail on the bugs they guard |
- The problem · 2. Design principles ·
- Architecture · 4. Pipelines ·
- Analyst process · 6. Data model ·
- Governance · 8. Evaluation · 9. Running it ·
- Demo walkthrough · 11. Known limitations ·
- Repository layout · 13. Status and roadmap · A. Technical reference
An AML analyst at a GCC risk-ops centre supporting a global bank works an alert like this:
- A monitoring system flags an account.
- The analyst pulls its transactions from one system, the customer's KYC profile from another.
- They look up the bank's AML policy and the FinCEN / FATF guidance to decide whether the activity actually breaches anything.
- They write up a decision, and if it is suspicious, a Suspicious Activity Report (SAR).
- Months later an auditor asks "why did you decide that?", and every step must be traceable.
That takes hours per alert, most alerts are false positives, and the reasoning usually lives in someone's head or a spreadsheet.
Risk Copilot runs the whole flow inside Snowflake, driven from the CoCo CLI:
SIGNAL → EVIDENCE → FINDING → REPORT
deterministic rules transactions + KYC + analyst decision, SAR draft citing
raise an alert cited policy clauses append-only log every fact it uses
| Persona | What they do here |
|---|---|
| AML analyst (daily user) | Works the alert queue, investigates, escalates or dismisses |
| Compliance officer | Reviews escalations, approves and files SARs |
| Auditor | Read-only: sees every decision and its evidence, with customer PII masked |
| Risk / compliance lead (buyer) | Rule performance, alert volumes, time to disposition |
- The LLM never decides what is fraud. Detection is deterministic SQL and Snowpark, written down in policy clauses. The AI explains evidence and drafts text; a named human decides.
- Every claim cites its source. A transaction ID, or a policy / regulation clause retrieved by Cortex Search. A statement with no citation is not allowed in an answer or a SAR.
- Decisions are append-only. Analyst dispositions go to a
FINDINGSlog that the analyst role can insert into but never update or delete. - Measured, not claimed. Every rule is scored for precision and recall against hidden ground-truth labels that the rules themselves never read.
- Anything the AI explains is stored as data first. Severity reasons, policy clauses and evidence numbers are columns, not inferences. Every time a fact was left implicit in testing, the model filled the gap with a plausible wrong answer.
- Everything stays in Snowflake. Data, detection, retrieval, AI, governance and the app run in one account. Nothing leaves it.
flowchart TB
U["👤 AML analyst · Compliance officer · Auditor"]
subgraph IF["Interfaces"]
direction LR
COCO["CoCo CLI skills<br/>$aml-detect · $aml-investigate · $sar-draft"]
APP["Streamlit command centre<br/>officer copy · auditor copy"]
end
subgraph AI["AI & evidence — explains, never decides"]
direction LR
CSS["Cortex Search<br/>policy + FinCEN + FATF clauses"]
AGT["Cortex Agent AML_COPILOT<br/>Analyst on semantic view"]
end
subgraph FND["Findings — audit trail"]
direction LR
FIN["FINDINGS<br/>append-only analyst decisions"]
SAR["SAR_REPORTS<br/>AI_COMPLETE drafts · citation check"]
end
subgraph DET["Detection — deterministic"]
direction LR
SQLR["SQL rules<br/>structuring · velocity · pass-through · geo-risk"]
CYC["Snowpark proc<br/>round-trip cycles"]
ALR["ALERTS → ALERT_QUEUE"]
end
subgraph DATA["Data"]
direction LR
RAW["RAW_* tables<br/>generated CSVs"]
CAN["CUSTOMERS · ACCOUNTS<br/>TRANSACTIONS"]
REF["FATF_JURISDICTIONS"]
end
GOV["🔒 Governance — 3 roles · PII-masking secure views · append-only grants"]
U --> IF
IF --> AI
IF --> FND
AI --> DET
FND --> DET
DET --> DATA
RAW --> CAN
SQLR --> ALR
CYC --> ALR
GOV -.- DATA
GOV -.- FND
Requests flow top to bottom; every layer runs inside one Snowflake account.
| Feature | Where | Why |
|---|---|---|
| CoCo CLI + project skills | .cortex/skills/ |
The analyst's interface; also how the demo is driven |
Stages, file formats, COPY INTO |
01_load_raw.sql |
Load the generated CSVs unchanged |
| Snowpark Python stored procedure | detection/cycles.py → DETECT_ROUND_TRIPS() |
Time-ordered graph search for layering loops, without data leaving Snowflake |
SQL window functions, QUALIFY |
20_detection_rules.sql |
Rolling-window rules with deterministic tie-breaks |
AI_PARSE_DOCUMENT, SPLIT_TEXT_MARKDOWN_HEADER |
40_cortex_search.sql |
Turn policy and FinCEN PDFs into searchable chunks |
| Cortex Search | AML_POLICY_SEARCH |
Cite the exact policy / regulation text |
| Semantic view + Cortex Analyst | AML_SEMANTIC_VIEW |
Plain-English questions become governed SQL over business names |
| Cortex Agent | AML_COPILOT |
One assistant that picks data or documents per question |
AI_COMPLETE (claude-sonnet-4-5, cross-region) |
Streamlit SAR drafting | Draft FinCEN-structured narratives from gathered evidence |
| Roles, grants, secure views | 80_governance.sql, 60_semantic_view.sql |
Least privilege, append-only decisions, PII masking |
| Streamlit in Snowflake | AML_COMMAND_CENTRE, AML_AUDIT_VIEW |
Command centre; owner's-rights apps make Snowflake enforce each role |
| Layer | Snowflake features | Status |
|---|---|---|
| 1 · Data | Stages, COPY INTO, tables, file formats |
✅ Live on Snowflake |
| 2 · Detection | SQL, Snowpark Python stored procedure | ✅ Live on Snowflake |
| 3 · Evidence | Cortex Search, AI_PARSE_DOCUMENT, SPLIT_TEXT_MARKDOWN_HEADER |
✅ Live (471 chunks, 5 documents: policy, FinCEN, FATF) |
| 3 · Evidence | Semantic view, Cortex Analyst, Cortex Agent | ✅ Live; answers verified against hand-written SQL |
| 4 · Finding | Tables + role grants (append-only) | ✅ Live; append-only enforced by grants |
| 5 · Governance | RBAC roles, secure views (masking), grants | ✅ Live; 36/36 role checks pass |
| App | Streamlit in Snowflake (two copies, owner's rights) | ✅ Live; full flow tested through the UI |
| Interface | CoCo CLI project skills (.cortex/skills/) |
✅ All three skills verified end to end in CoCo v1.1.87 |
No external services: no Pinecone, no OpenAI, no LangChain. Each component has a native Snowflake equivalent.
| If you know… | Snowflake's version | What it is | Ours |
|---|---|---|---|
| Pinecone / Weaviate (managed vector search) | Cortex Search | Managed hybrid search engine: vector + keyword + semantic re-ranker, indexed and refreshed by Snowflake. Built on search technology from Neeva (acquired by Snowflake in 2023) | AML_POLICY_SEARCH |
| OpenAI embeddings | Arctic Embed | Snowflake's own open embedding models; Cortex Search embeds chunks with one automatically | snowflake-arctic-embed-m-v1.5 |
| pgvector (vectors in a table) | VECTOR data type + AI_EMBED, VECTOR_COSINE_SIMILARITY |
Native vector column and functions, for hand-built similarity search | Not needed: Cortex Search manages its own vectors |
| dbt metrics / LookML (semantic layer) | Semantic View | Metadata only, no vectors: business names, synonyms, joins, metrics, SQL-writing rules | AML_SEMANTIC_VIEW |
| Text-to-SQL (Vanna, LlamaIndex SQL) | Cortex Analyst | Reads the semantic view, writes SQL, runs it as the asking role | Tool AmlData |
| LangChain / LangGraph agent | Cortex Agent | Chooses tools per question, combines their results, answers with citations | AML_COPILOT |
| OpenAI / Anthropic API | AI_COMPLETE (Cortex AI functions) |
LLM call from SQL; Claude, Llama and others hosted in Snowflake, cross-region if needed | claude-sonnet-4-5 |
| Unstructured.io / PyMuPDF | AI_PARSE_DOCUMENT |
PDF → text with layout (headings, tables) | LAYOUT mode |
| LangChain text splitters | SPLIT_TEXT_MARKDOWN_HEADER |
Chunk by markdown headers with size + overlap | 2,000 chars, 300 overlap |
| AWS Lambda / Python service | Snowpark stored procedure | Python running inside Snowflake next to the data | DETECT_ROUND_TRIPS() |
| Streamlit Cloud / Vercel | Streamlit in Snowflake | App hosted in the account, runs with its owner's role | AML_COMMAND_CENTRE, AML_AUDIT_VIEW |
| Claude Code / Copilot CLI | CoCo (Cortex Code) CLI | Snowflake's coding agent with project skills | .cortex/skills/ |
| Okta groups / app-level permissions | Roles + grants + secure views | Access enforced by the database itself | 3 roles, 2 secure views |
How the semantic side and the search side differ:
| Semantic View + Cortex Analyst | Cortex Search | |
|---|---|---|
| Data | Structured tables | Unstructured documents (PDF text) |
| Uses vectors? | No: names, joins and metrics as metadata | Yes: Arctic Embed vectors, plus keyword and re-ranker |
| Produces | SQL, run as the asking role | Ranked chunks with source and scores |
| Answers | "How much cash did high-risk customers deposit?" | "What does our policy say about structuring?" |
| Governed by | Grants and secure views on the underlying tables | USAGE on the search service |
The Cortex Agent sends each question to one, the other, or both. That is the "hybrid" in the copilot, on top of the hybrid retrieval inside Cortex Search.
flowchart LR
G["Synthetic generator<br/>Udith"] --> CSV["data/*.csv"]
CSV -->|"PUT @RAW_DATA"| STG["Stage RAW_DATA"]
STG -->|"COPY INTO<br/>01_load_raw.sql"| RAW["RAW_CUSTOMERS<br/>RAW_ACCOUNTS<br/>RAW_TRANSACTIONS<br/>RAW_LABELS<br/>RAW_HIGH_RISK_COUNTRIES"]
RAW -->|"02_canonical.sql"| CAN["CUSTOMERS<br/>ACCOUNTS<br/>TRANSACTIONS<br/>TXN_LABELS"]
RAW -->|"05_reference.sql"| REF["FATF_JURISDICTIONS<br/>FATF lists + bank's own list"]
Raw files are loaded unchanged; one SQL step maps them onto the contract every other component depends on. A new data source only needs a new mapping, never a rule change.
What 02_canonical.sql does:
| Raw shape | Canonical shape | Rule |
|---|---|---|
One-sided row: account_id + counterparty_account |
FROM_ACCOUNT_ID → TO_ACCOUNT_ID |
deposit or "incoming…" description = inflow; otherwise outflow. External side is NULL |
txn_type + channel + description |
CHANNEL = CASH / WIRE / INTERNAL / UPI / ACH |
"cash…" description, or deposit/withdrawal at ATM/branch = CASH |
kyc_risk_rating, expected_monthly_volume |
RISK_RATING, DECLARED_MONTHLY_INCOME |
Upper-cased; volume is the §7.3 income baseline |
| Generator typology names | TYPOLOGY |
RAPID_LAYERING → LAYERING, ROUND_TRIPPING → ROUND_TRIP, … |
currency = INR |
CURRENCY = USD |
Typologies are sized for US BSA thresholds; see data/README.md |
flowchart LR
T["TRANSACTIONS"] --> R1["§3.3 STRUCTURING<br/>SQL"]
T --> R2["§4.1 VELOCITY<br/>SQL"]
T --> R3["§5.5 PASS_THROUGH<br/>SQL"]
T --> R4["§6.2 GEO_RISK<br/>SQL"]
F["FATF_JURISDICTIONS"] --> R4
T --> R5["§5.2 ROUND_TRIP_CYCLE<br/>Snowpark: cycles.py"]
R1 --> AL["ALERTS<br/>rule · account · txn ids · severity · evidence JSON"]
R2 --> AL
R3 --> AL
R4 --> AL
R5 --> AL
AL --> Q["ALERT_QUEUE view<br/>§7.2 high-risk customers +1 severity"]
AL --> X["ALERT_TXNS view<br/>one row per alert × txn"]
L["TXN_LABELS<br/>ground truth"] -.->|"evaluation only"| M["RULE_METRICS<br/>30_evaluate.sql"]
X --> M
| Rule | Policy | Logic | Severity |
|---|---|---|---|
STRUCTURING |
§3.3 | ≥3 cash deposits of $8,000–$9,999 within 7 days, total > $10,000 | HIGH |
VELOCITY |
§4.1 | ≥4 transactions in any rolling 24h, value > 5× trailing 90-day daily average, min $10,000 | MEDIUM; HIGH if no prior activity |
PASS_THROUGH |
§5.5 | Inflow ≥ $50,000, then ≥2 outflows totalling ≥50% of it within 72h | HIGH |
GEO_RISK |
§6.2 | Any wire with a FATF black-list country; grey-list wires > $50,000 in 30 days | CRITICAL / HIGH |
ROUND_TRIP_CYCLE |
§5.2 | Money leaves and returns through 2–4 time-ordered hops within 7 days, each hop ≥80% of the last | MEDIUM (2 hops) / HIGH (3+) |
Properties every rule shares:
- Stable on rerun: rules write candidates; only alerts with a new key
(
MD5(rule | sorted txn ids)) are inserted. Rerunning never renumbers an alert or resets its status, soFINDINGSandSAR_REPORTSalways point at the alert the analyst reviewed. - Deterministic: every "keep the best window" breaks ties by earliest time, then txn id.
- Explainable:
EVIDENCEstores the numbers that fired it (totals, multiples, window, policy clause). - Traceable:
TXN_IDSstores the exact transactions, so every alert links to raw evidence. - Severity bump is a view, not an update: reruns never escalate the same alert twice, and
ALERT_QUEUE.SEVERITY_REASONstates why in words the AI repeats verbatim.
Why the cycle rule is Python, not SQL. Finding A→B→C→A where each transfer happens after
the previous one is a graph search with time constraints. detection/cycles.py runs a bounded
depth-first search over time-ordered edges. The idea comes from Pranav's Arbix project
(Bellman-Ford cycle detection), but Bellman-Ford ignores time order and would report loops
money could not actually have travelled. It runs as a Snowpark stored procedure, so data never
leaves Snowflake.
flowchart LR
P["internal_aml_policy.pdf<br/>FinCEN SAR guidance (2 PDFs)<br/>FATF Recommendations + PML report"] -->|"PUT"| S["Stage REG_DOCS"]
S -->|"AI_PARSE_DOCUMENT<br/>LAYOUT mode"| RT["REG_DOCS_RAW"]
RT -->|"SPLIT_TEXT_MARKDOWN_HEADER<br/>2000 chars, 300 overlap"| CH["REG_DOC_CHUNKS<br/>source · section · chunk"]
CH --> CS["Cortex Search service<br/>AML_POLICY_SEARCH"]
CS -->|"SEARCH_PREVIEW"| Q["Skills / Agent<br/>cite source + section"]
Retrieval is hybrid, and no data leaves Snowflake (no external vector database). Every
Cortex Search hit carries three scores, visible in SEARCH_PREVIEW output:
| Score | Kind |
|---|---|
text_match |
Keyword (lexical) match |
cosine_similarity |
Vector match, embedding model snowflake-arctic-embed-m-v1.5 |
reranker_score |
Semantic re-ranker that sets the final order |
For "cash deposits just below the reporting threshold" the policy chunk ranks first although a FinCEN chunk has the higher keyword score: the re-ranker judged it more relevant. The copilot is hybrid across data types too: the agent sends each question to text-to-SQL over the semantic view (structured), to Cortex Search (documents), or both (section 4.4).
Chunks keep their source file and section header. FinCEN chunks carry real section headers ("Organizing Information in the SAR Narrative"); the policy PDF is rendered from plain text, so its chunks carry the document title and the clause numbers (§3.3, §5.2 …) live in the chunk text, which is what answers cite. 471 chunks: FATF Recommendations 256, FATF Professional Money Laundering 96, FinCEN filing instructions 64, FinCEN SAR narrative guidance 44, policy 11.
Clause lookups are filtered to the bank's policy. Adding 352 FATF chunks made an unfiltered
"policy section 6.2" search return FATF text, and the geo-risk alert lost its clause in the app.
The app and $aml-investigate now pass "filter": {"@eq": {"SOURCE": "internal_aml_policy.pdf"}}
for the clause and search regulator guidance separately. Checked for all five rules; the agent
answers FATF questions from the new documents (e.g. Recommendation 12 on PEPs).
The internal policy (corpus/internal_aml_policy.md) is a fictional bank's monitoring policy
written for this project. Every detection rule is a numbered clause in it, which is what
lets the copilot say which rule an account broke and where that rule is written down.
flowchart LR
Q["Analyst question<br/>Streamlit chat"] --> AG["Cortex Agent<br/>AML_COPILOT · claude-sonnet-4-5"]
AG -->|"data questions"| AN["Cortex Analyst"]
AN --> SV["AML_SEMANTIC_VIEW<br/>business names, synonyms, metrics"]
SV --> SEC["CUSTOMER_PROFILE secure view<br/>masks names by role"]
SV --> AA["ACCOUNT_ACTIVITY · ALERT_FACTS · FINDINGS"]
AG -->|"policy questions"| CS["Cortex Search<br/>AML_POLICY_SEARCH"]
AN --> A["Answer + table + generated SQL"]
CS --> A
The semantic view gives the model business meaning instead of raw columns: "cash deposits"
is CHANNEL = 'CASH' AND DIRECTION = 'IN', "high-risk" is RISK_RATING = 'HIGH', and
SEVERITY_REASON is the only allowed explanation of a severity. Every query runs as the
asking role, so an auditor's answer comes back with names masked, by Snowflake, not by the app.
| Question asked | Tools used | Checked |
|---|---|---|
| "How many open alerts are there by rule and severity?" | AmlData | Matches ALERT_QUEUE |
| "Which 3 high-risk customers deposited the most cash?" | AmlData | Matches hand-written SQL to the cent ($75,815.11 / $54,553.74 / $52,167.09) |
| "What does our policy say about structuring?" | AmlPolicy | Quotes §3.1–3.3 and the $10,000 CTR line |
| "Why was alert 37 raised, why is it CRITICAL?" | AmlData + AmlPolicy | §3.3, 4 txn IDs, $38,296.10, and the stored §7.2 reason |
| Same cash question, as AUDITOR | AmlData | Same numbers, names *** masked *** |
sequenceDiagram
autonumber
actor An as AML analyst
participant Co as CoCo CLI
participant Sf as Snowflake
participant Cs as Cortex Search
actor Of as Compliance officer
An->>Co: $aml-detect
Co->>Sf: run rules + DETECT_ROUND_TRIPS
Sf-->>Co: ALERT_QUEUE by rule and severity
Co-->>An: top 5 alerts with policy clause
An->>Co: $aml-investigate 42
Co->>Sf: alert, its transactions, customer KYC, linked alerts
Co->>Cs: search policy + FinCEN/FATF for this pattern
Cs-->>Co: clauses with source and section
Co-->>An: explanation, every claim cited
An->>Co: escalate, reason "no business purpose for intermediaries"
Co->>Sf: INSERT INTO FINDINGS, ALERTS.STATUS = ESCALATED
An->>Co: $sar-draft 42
Co->>Sf: check alert was escalated by a human
Co->>Cs: FinCEN narrative guidance
Co->>Sf: INSERT INTO SAR_REPORTS status DRAFT
Co-->>An: who / what / when / where / why, cited
Of->>Sf: review and approve the draft
| Step | Who decides | What is recorded |
|---|---|---|
| Alert raised | A deterministic rule | ALERTS: rule, txn ids, evidence numbers, policy clause |
| Investigation | Nobody: evidence is gathered and explained | Nothing new, read-only |
| Disposition | The analyst, by name, with a reason | FINDINGS: decision, analyst, reason, evidence txns, clauses, role, timestamp |
| SAR draft | The AI drafts, only after a human escalation | SAR_REPORTS: narrative, cited txns, cited sources, status DRAFT |
| Filing | The compliance officer | SAR_REPORTS.STATUS → APPROVED / FILED |
The same flow runs in the Streamlit command centre: alert queue → Investigate tab
(evidence, KYC, linked alerts, the policy clause text from Cortex Search, decision form) →
SARs tab (Cortex AI draft with claude-sonnet-4-5, citation check, officer approval, which
stays disabled until every cited transaction verifies) → Audit tab (decision log, rule
metrics). Ask the copilot is a chat over the Cortex Agent.
Project skills live in .cortex/skills/ and are called as $skill-name
(CoCo extensibility docs).
| Skill | Input | Does | Writes |
|---|---|---|---|
$aml-detect |
— | Runs all rules + cycle proc; summarises queue; shows precision/recall on request | ALERTS |
$aml-investigate |
ALERT_ID |
Pulls txns, KYC, linked alerts, policy + regulation clauses; explains with citations; records the analyst's decision | FINDINGS, ALERTS.STATUS |
$sar-draft |
escalated ALERT_ID |
Refuses unless a human escalated it; drafts a FinCEN-structured narrative with a citation on every claim; no legal conclusions; runs the citation check | SAR_REPORTS |
SAR citation check. SAR_CITATION_CHECK extracts every TXN_… ID from a narrative and
verifies it exists and belongs to the case. Tested against a planted bad draft: it flagged a
made-up ID and a real transaction from another customer, and passed the genuine one.
erDiagram
CUSTOMERS ||--o{ ACCOUNTS : owns
ACCOUNTS ||--o{ TRANSACTIONS : "sends / receives"
TRANSACTIONS ||--o| TXN_LABELS : "ground truth"
ACCOUNTS ||--o{ ALERTS : raises
ALERTS }o--o{ TRANSACTIONS : "TXN_IDS evidence"
ALERTS ||--o{ FINDINGS : "decided in"
ALERTS ||--o{ SAR_REPORTS : "reported in"
FATF_JURISDICTIONS ||--o{ TRANSACTIONS : "COUNTERPARTY_COUNTRY"
CUSTOMERS {
string CUSTOMER_ID PK
string NAME
string RISK_RATING
boolean PEP_FLAG
number DECLARED_MONTHLY_INCOME
}
ACCOUNTS {
string ACCOUNT_ID PK
string CUSTOMER_ID FK
string ACCOUNT_TYPE
}
TRANSACTIONS {
string TXN_ID PK
string FROM_ACCOUNT_ID "NULL = external"
string TO_ACCOUNT_ID "NULL = external"
number AMOUNT
string CHANNEL
string COUNTERPARTY_COUNTRY
timestamp TXN_TS
}
ALERTS {
number ALERT_ID PK
string RULE
string ACCOUNT_ID
array TXN_IDS
string SEVERITY
variant EVIDENCE
string STATUS
string ALERT_KEY "MD5(rule | sorted txn ids)"
}
FINDINGS {
number FINDING_ID PK
number ALERT_ID FK
string DECISION
string ANALYST
string REASON
array POLICY_REFS
string DECIDED_BY_ROLE
timestamp DECIDED_AT
}
SAR_REPORTS {
number SAR_ID PK
number ALERT_ID FK
string STATUS
string NARRATIVE
array CITED_TXN_IDS
array CITED_SOURCES
}
All detection, the semantic model and the app depend on these names. All amounts in USD.
CUSTOMERS (CUSTOMER_ID, NAME, CUSTOMER_TYPE, RISK_RATING, PEP_FLAG, OCCUPATION,
DECLARED_MONTHLY_INCOME, COUNTRY, ONBOARDED_AT)
ACCOUNTS (ACCOUNT_ID, CUSTOMER_ID, ACCOUNT_TYPE, OPENED_AT, STATUS)
TRANSACTIONS (TXN_ID, FROM_ACCOUNT_ID, TO_ACCOUNT_ID, COUNTERPARTY_REF, AMOUNT, CURRENCY,
CHANNEL, ACCESS_POINT, COUNTERPARTY_COUNTRY, TXN_TS, DESCRIPTION)
-- FROM/TO = internal ACCOUNT_ID, or NULL for the external side
-- cash deposit = FROM NULL, TO the account, CHANNEL 'CASH'
-- CHANNEL in (CASH, WIRE, ACH, UPI, INTERNAL)
-- COUNTERPARTY_COUNTRY = ISO-2 code, matches FATF_JURISDICTIONS
TXN_LABELS (TXN_ID, ACCOUNT_ID, TYPOLOGY) -- ground truth; detection never reads it
Views built on top: ALERT_QUEUE (alerts + EFFECTIVE_SEVERITY + SEVERITY_REASON),
ALERT_TXNS (alert × transaction), ACCOUNT_ACTIVITY (transaction × internal account side),
ALERT_FACTS, CUSTOMER_PROFILE and SAR_REVIEW (secure, masked), SAR_CITATION_CHECK,
RULE_METRICS.
Three roles (sql/80_governance.sql), checked live by scripts/check_governance.py: 36 / 36 pass
(12 checks per role: read alerts, names visible or masked in tables and through the AI layer,
raw table access, ground truth, semantic view, Cortex Search, the agent, record / edit / delete a
decision, approve a SAR).
| Role | Customer names | Record a decision | Edit / delete a decision | Approve a SAR | Fraud labels |
|---|---|---|---|---|---|
AML_ANALYST |
visible | ✅ | ❌ | ❌ | ❌ |
COMPLIANCE_OFFICER |
visible | ✅ | ❌ | ✅ | ❌ |
AUDITOR |
*** masked *** (tables, semantic view, agent answers, SAR narratives) |
❌ | ❌ | ❌ | ❌ |
| Control | How |
|---|---|
| Append-only decisions | Analysts get INSERT on FINDINGS and SAR_REPORTS, never UPDATE / DELETE. FINDINGS.DECIDED_BY_ROLE records the role that made each decision |
| PII masking | CUSTOMER_PROFILE and SAR_REVIEW secure views mask by CURRENT_ROLE(); the auditor has no grant on any table that holds a name; the semantic view reads customers through the secure view |
| Ground-truth isolation | No role that works alerts can read TXN_LABELS / RAW_LABELS; RULE_METRICS still works for them because a view runs with its owner's rights |
| Grants survive rebuilds | Every recreated table and view uses COPY GRANTS; the search service and agent (which don't accept it) are re-granted by 80_governance.sql, which deploy.py always runs after them |
| App enforcement | The Streamlit app is deployed twice, created by COMPLIANCE_OFFICER and by AUDITOR. An app runs with its owner's role, so what each copy can see and write is Snowflake's decision, not the app's |
Two things we learned building it:
- Masking policies need Enterprise edition. The CoCo trial is Standard (
Unsupported feature 'MASKING POLICY'), so masking is done with secure views. On Enterprise the same columns would take aPIItag with a tag-based masking policy. DEFAULT_SECONDARY_ROLES = ('ALL')is the default for users. A session "as AUDITOR" then still carries the user's other roles, and every permission check passes for the wrong reason. Masking keys onCURRENT_ROLE()and the governance check runsUSE SECONDARY ROLES NONE.
The generator plants known typologies and records them in TXN_LABELS. Only
sql/30_evaluate.sql reads that table. RULE_METRICS reports, per rule:
| Metric | Meaning |
|---|---|
PRECISION |
Alerts that contain at least one truly suspicious transaction / all alerts |
TYPOLOGY_PRECISION |
Same, but the label must match the rule's own typology. Lower for velocity by design: a spike on a cycle or FATF account is a real catch under another name |
CASE_RECALL |
Labelled accounts with at least one transaction in a matching alert / labelled accounts. The headline number: one alert per scheme is enough for an analyst |
RECALL |
Same at transaction level. Understates bursts longer than the rule window |
| Rule | Alerts | Precision | Cases caught |
|---|---|---|---|
| STRUCTURING | 12 | 1.00 | 12 / 12 |
| VELOCITY | 32 | 0.75 | 12 / 12 |
| PASS_THROUGH | 14 | 0.93 | 10 / 11 |
| ROUND_TRIP_CYCLE | 4 | 1.00 | all 4 cycles (12 accounts) |
| GEO_RISK | 6 | 1.00 | 6 / 6 |
Identical on Snowflake and locally: SELECT * FROM RISK_COPILOT.AML.RULE_METRICS returns
exactly the table the DuckDB harness prints.
The one miss is a layering account that passed on 47.9% of its inflow, under the 50% rule threshold. Caveat: velocity and pass-through thresholds were calibrated on this file, so these numbers are optimistic. Final numbers come from a second, differently seeded file that no threshold was tuned against.
Results on data_holdout/ (41,630 transactions, 144 labelled, seed 20259 — nothing tuned against this file)
| Rule | Alerts | Precision | Cases caught |
|---|---|---|---|
| STRUCTURING | 8 | 1.00 | 8 / 8 |
| VELOCITY | 28 | 0.71 | 9 / 9 |
| PASS_THROUGH | 13 | 1.00 | 13 / 13 |
| ROUND_TRIP_CYCLE | 4 | 1.00 | all 4 cycles (12 accounts) |
| GEO_RISK | 11 | 1.00 | 11 / 11 |
Run with python3 tests/run_on_holdout.py (see data_holdout/README.md). Case recall is
1.00 across every rule — every planted scheme produces at least one alert. VELOCITY's lower
transaction-level precision reproduces on fresh data too, confirming it's a genuinely harder
pattern (bursty legitimate activity looks similar) rather than an artifact of tuning.
PASS_THROUGH's one miss on data/ doesn't reproduce here, because the generator's
rapid-layering outflow fraction was widened from 25–40% to 35–45% per step (worst case ~58%
vs. the old worst case ~44%, which is what originally fell under the 50% threshold) — see
data_holdout/README.md for the full explanation.
Caveat on PASS_THROUGH (checked by re-generation). That generator change was made after seeing the rule's 50% threshold, so the held-out PASS_THROUGH result is not independent of the rule. Regenerating the same seed (20259) with the original 25–40% setting gives:
| Rule | Held-out, adjusted generator | Held-out, original generator (independent) |
|---|---|---|
| PASS_THROUGH | 13 / 13 cases, precision 1.00 | 10 / 13 cases (0.77), precision 1.00 |
| All other rules | as above | identical |
Numbers to quote: structuring 8/8, geo-risk 11/11, round-trip all 4 cycles, velocity 9/9 cases (precision 0.71), pass-through 10/13 — every rule with precision ≥ 0.71 on data no threshold was tuned against. The three pass-through misses send out under half their inflow; catching them means lowering the 50% line, which would need its own false-positive check.
- Snowflake account. Sign up on the Snowflake CoCo – For Developers tab at
signup.snowflake.com (includes $40 CoCo + $360 platform credits). Our build uses an
AWS ap-northeast-1 (Tokyo) trial account, Standard edition. Claude models are not
hosted in every region, so cross-region inference must be on (it was on our trial):
ALTER ACCOUNT SET CORTEX_ENABLED_CROSS_REGION = 'ANY_REGION';On our accountclaude-sonnet-4-5andllama3.1-70bwork;claude-4-sonnetandmistral-large2are retired ("legacy state"). - CoCo CLI (macOS / Linux):
curl -LsS https://ai.snowflake.com/static/cc-scripts/install.sh | sh # installs ~/.local/bin/cortex cortex --version # built on v1.1.87 - Connect. Run
cortexonce; the wizard creates~/.snowflake/connections.tomlwith browser (OAuth) login. Then make sure the connection has a warehouse and our schema:If the wizard shows an Agent connection and a SQL connection, both must be the same account, or tables land in one account and AI usage bills another.default_connection_name = "<your connection>" [<your connection>] account = "<account>.<region>.aws" user = "<USER>" authenticator = "OAUTH_AUTHORIZATION_CODE" role = "ACCOUNTADMIN" warehouse = "COMPUTE_WH" database = "RISK_COPILOT" schema = "AML"
- Python 3.11 for the scripts and tests:
pip install -r requirements.txt.
One command uploads the files and runs every script, in order, into RISK_COPILOT.AML:
python3 scripts/deploy.py # default connection from ~/.snowflake/connections.toml
python3 scripts/deploy.py -c <connection> # or a named one
python3 scripts/deploy.py --only 20_detection_rules.sql 30_evaluate.sql # rerun part of it
Order: 01_load_raw → 02_canonical → 05_reference → 10_alerts_and_cycle_proc → 20_detection_rules → 30_evaluate → 50_findings → 40_cortex_search → 60_semantic_view → 70_agent → 80_governance → 90_streamlit. Every script is idempotent; 80 and 90 must run last (recreating a table drops
its grants).
For unattended runs, use key-pair auth instead of the browser login (which expires hourly):
generate a key with openssl, ALTER USER <you> SET RSA_PUBLIC_KEY = '…', and add a connection
with authenticator = "SNOWFLAKE_JWT" and private_key_file. Keep the key out of the repo.
What it creates:
| Kind | Objects |
|---|---|
| Stages | RAW_DATA, CODE_STAGE, REG_DOCS |
| Raw tables | RAW_CUSTOMERS, RAW_ACCOUNTS, RAW_TRANSACTIONS, RAW_LABELS, RAW_HIGH_RISK_COUNTRIES |
| Contract tables | CUSTOMERS, ACCOUNTS, TRANSACTIONS, TXN_LABELS, FATF_JURISDICTIONS |
| Detection | ALERTS, ALERT_CANDIDATES, procedure DETECT_ROUND_TRIPS(), views ALERT_QUEUE, ALERT_TXNS |
| Evaluation | views RULE_TYPOLOGY, RULE_METRICS |
| Evidence | REG_DOCS_RAW, REG_DOC_CHUNKS (471 chunks), Cortex Search service AML_POLICY_SEARCH |
| Findings | FINDINGS, SAR_REPORTS, view SAR_CITATION_CHECK |
| Question layer | views ACCOUNT_ACTIVITY, ALERT_FACTS; semantic view AML_SEMANTIC_VIEW; agent AML_COPILOT |
| Governance | roles AML_ANALYST, COMPLIANCE_OFFICER, AUDITOR; secure views CUSTOMER_PROFILE, SAR_REVIEW |
| App | stage APP_STAGE; Streamlit AML_COMMAND_CENTRE (officer), AML_AUDIT_VIEW (auditor) |
cd risk-copilot
cortex
> $aml-detect
> $aml-investigate <ALERT_ID>
> $sar-draft <ALERT_ID>
CoCo asks for approval before any statement that changes data (INSERT, DELETE, …). In
the demo that is deliberate: the analyst approves each write. cortex exec (non-interactive)
auto-rejects those prompts, so it can only run the read-only parts.
A real $aml-detect run on the deployed data (CoCo v1.1.87):
| Rule | Severity | Open alerts |
|---|---|---|
| GEO_RISK | CRITICAL | 2 |
| GEO_RISK | HIGH | 4 |
| PASS_THROUGH | HIGH | 14 |
| ROUND_TRIP_CYCLE | HIGH | 4 |
| STRUCTURING | CRITICAL | 1 |
| STRUCTURING | HIGH | 11 |
| VELOCITY | HIGH | 1 |
| VELOCITY | MEDIUM | 31 |
Top of the queue: two wires to FATF black-list countries (North Korea, Myanmar; $360,934 and $289,639), then a structuring case raised to CRITICAL because the customer is high-risk (§7.2).
Snowsight → Projects → Streamlit → AML_COMMAND_CENTRE (works cases: decide, draft and
approve SARs) or AML_AUDIT_VIEW (read-only, PII masked). Direct link format:
https://app.snowflake.com/<org>/<account>/#/streamlit-apps/RISK_COPILOT.AML.AML_COMMAND_CENTRE.
Opening an app needs a role that can use it; the deploying admin can open both.
| Tab | What it shows |
|---|---|
| Alert queue | KPIs (open, critical, escalated, SARs), filters by rule / severity / status, alerts by rule |
| Investigate | Severity and its stored reason, the policy clause text (Cortex Search), rule evidence, the transactions, KYC, linked alerts, decision form |
| Ask the copilot | Chat with the Cortex Agent; answers with tables and the SQL Cortex Analyst generated |
| SARs | Draft from an escalated alert with Cortex AI; citation check; approve (officer only, and only when every citation verifies) |
| Audit & metrics | Append-only decision log with the deciding role; rule precision / recall |
Every skill was run through CoCo against the deployed data, and every fact it stated was checked against the database by query.
| # | Test | Result |
|---|---|---|
| T1 | $sar-draft on an alert no human reviewed |
✅ Refused; no SAR written |
| T2 | $aml-investigate (structuring, CRITICAL) |
✅ All 4 txn IDs, amounts, times, $38,296.10 total, KYC, linked alert verified by query |
| T3 | Escalate with analyst name + reason | ✅ One FINDINGS row (4 evidence txns, §3.3 + §7.2); alert ESCALATED |
| T4 | $sar-draft after escalation |
✅ DRAFT saved; citation check 4 / 4; no legal conclusion; correct severity reason |
| T5 | $aml-investigate on a money loop |
✅ Showed the 3-hop loop and linked the middle account's own pass-through alert |
| T6 | Dismiss a false-positive velocity alert | ✅ DISMISSED, reason recorded |
| T7 | Rerun all detection after a decision | ✅ 68 → 68 alerts, ids/keys/statuses identical (scripts/check_rerun_stable.py) |
| T8 | Agent: data, policy and mixed questions | ✅ Answers verified against hand-written SQL (section 4.4) |
| T9 | Governance, every role × every action | ✅ 36 / 36 (scripts/check_governance.py) |
| T10 | App renders as officer and as auditor | ✅ 0 exceptions; officer sees names + decision form; auditor sees masks, no form (tests/test_app.py) |
| T11 | Full flow through the UI: escalate → AI SAR → approve | ✅ Finding recorded with role COMPLIANCE_OFFICER; 308-word SAR; citations 4 / 4; approved (tests/test_app_flow.py) |
| T12 | Chat through the UI, as officer and as auditor | ✅ Same numbers; auditor's table masked (tests/test_app_chat.py) |
| T13 | Full redeploy from scratch, then every check again | ✅ Metrics identical; 36 / 36 governance; UI flow and masked chat pass |
| T14 | Rerun detection, then governance | ✅ No role loses access (COPY GRANTS) |
What testing caught and fixed along the way:
| Found | Fix |
|---|---|
| Cited §7.3 (income) as breached at 1.43×; the clause needs 3× for two months | Skills must show the arithmetic and say "relevant but not met" |
| SAR said the subject acted "in violation of 31 U.S.C. § 5324" | Skills forbid legal conclusions: "appears consistent with" |
| SAR invented "UTC" for timestamps with no timezone | Skills forbid adding a timezone |
| Said severity was raised "because of a linked alert" (it was the customer's risk rating) | SEVERITY_REASON column; skills repeat it verbatim |
| Rerunning detection renumbered every alert, orphaning findings and SARs | Stable alert keys; regression test escalates, reruns, compares |
| Identical-amount bursts tied; Snowflake picked a different window per run (68 → 72 alerts) | Total-order tie-breaks; test shuffles row order, fails 3/3 without the fix |
| Agent said CRITICAL meant "pattern across linked accounts" | SEVERITY_REASON exposed in the semantic view; agent told to repeat it |
| Auditor could read names: masking policies unsupported on Standard | Secure views; auditor's table grants revoked; verified masked through the agent too |
Rule logic runs locally on DuckDB from the real SQL files (only Snowflake-only functions are
translated, tests/duck.py); the rest run against live Snowflake.
# local, no Snowflake needed
python3 detection/cycles.py # cycle detector self-check
python3 tests/test_rules_duckdb.py # every rule on planted typologies: case recall 1.0, rerun stable
python3 tests/run_on_data.py # full pipeline on data/: RULE_METRICS + rerun stable
python3 tests/run_on_holdout.py # same, on data_holdout/ (second seed, honest numbers)
# live on Snowflake
python3 scripts/check_rerun_stable.py # rerun never adds, renumbers or resets alerts
python3 scripts/check_governance.py # every role x every action, live on Snowflake
# the app, headless against live Snowflake (Python 3.11: streamlit==1.39.0, snowflake-snowpark-python)
APP_ROLE=COMPLIANCE_OFFICER python tests/test_app.py
APP_ROLE=AUDITOR python tests/test_app.py
APP_ROLE=COMPLIANCE_OFFICER python tests/test_app_flow.py 37 # escalate -> SAR -> approve, cleans up
APP_ROLE=AUDITOR python tests/test_app_chat.py
streamlit run streamlit/streamlit_app.py # or run it locally
| Symptom | Cause | Fix |
|---|---|---|
DETECT_ROUND_TRIPS crashed: Decimal * float |
Snowflake NUMBER arrives in Python as Decimal; DuckDB gave floats |
find_cycles casts amounts; the self-check now feeds it Decimals |
| CoCo cited money-loop alerts as §7.2 | Cycle alerts didn't record their clause, so the model guessed | Every rule writes EVIDENCE:policy; a query confirms 0 alerts without one |
| Skill said "SQL tool is blocked" | Docs say sql_execute; CoCo 1.1.87's tool is snowflake_sql_execute |
Skills declare snowflake_sql_execute (name taken from CoCo's bundled skills) |
| Tables in one account, credits in another | Wizard set Agent and SQL connections to different accounts | One connection only, set as default |
cortex sql … hung |
Not a real subcommand; it opened an interactive session | Use scripts/deploy.py or the skills |
AT alias failed |
Reserved word in DuckDB and Snowflake (Time Travel) | Renamed alias |
SEMANTIC_VIEW(... DIMENSIONS (a.b)) → "SELECT list element with multiple columns" |
Parentheses make a tuple | Write DIMENSIONS a.b, c.d without parentheses |
GRANT OWNERSHIP ON STREAMLIT unsupported |
Streamlit ownership can't move | Create each copy while using its owning role |
CREATE OR REPLACE MASKING POLICY fails when attached; masking unsupported on Standard |
Edition | Secure views |
| Agent tables missing from the text answer | Tables arrive as a separate table content item |
App renders result_set as a dataframe |
| Browser OAuth expires about hourly | Token lifetime | Key-pair auth for scripts |
| Role checks passed that shouldn't have | DEFAULT_SECONDARY_ROLES = ('ALL') keeps every granted role active |
USE SECONDARY ROLES NONE in checks; masking keys on CURRENT_ROLE() |
claude-4-sonnet, mistral-large2 errors |
Retired models ("legacy state") | claude-sonnet-4-5 |
| App behaviour could differ in Snowflake | Unpinned pandas resolves to 3.x there; tests ran on 2.3.3 |
Pinned streamlit=1.39.0, pandas=2.3.3 in environment.yml |
Restoring a file with git checkout lost uncommitted work |
It restores the committed version | Back up with cp before mutation tests |
| After a detection rerun, every role lost access to the alert queue | CREATE OR REPLACE VIEW drops grants |
COPY GRANTS on every recreated object; caught by running the governance check after the rerun check |
| After a full redeploy the app crashed for every role | Recreating the Cortex Search service dropped its grant; the governance check never tested search | Governance re-grants it last; the check now covers search and the agent (proven to fail first) |
| App showed "pyproject.toml file does not exist" in Snowsight, though headless tests passed | New Streamlits default to the container runtime (wants pyproject.toml); the app targets the warehouse runtime (environment.yml). Local tests can't see the runtime |
RUNTIME_NAME = 'SYSTEM$WAREHOUSE_RUNTIME' on both apps; caught only by opening the app in the browser |
| Geo-risk alert lost its policy clause after the FATF PDFs were indexed | Unfiltered search returned FATF text for "policy section 6.2" | Clause lookups filter on SOURCE = internal_aml_policy.pdf |
| Rerun check failed intermittently on the held-out data (velocity) | Three windows summed to exactly $19,376.44; DuckDB sums floats, so the "largest" flipped with row order | Window scores rounded to cents before comparing; 40 shuffled reruns identical. Snowflake's exact decimals were never affected |
COPY GRANTS rejected on the semantic view / agent |
Must be the last clause on a semantic view; agents don't accept it | Moved to the end; agent re-granted by 80_governance.sql |
About 4 minutes, recorded in the CoCo CLI as the submission requires, then the app. Start CoCo
with --no-mcp so personal MCP servers don't print connection noise into the recording.
| # | Do | Shows |
|---|---|---|
| 1 | cortex --no-mcp → $aml-detect |
Deterministic rules run; 68 alerts; North Korea / Myanmar wires and a structuring case on top, each with its policy clause |
| 2 | $aml-investigate 37 |
4 cash deposits of $9,391–$9,712 in one day = $38,296.10; §3.3 met; CRITICAL because the customer is high-risk (§7.2); §7.3 income clause relevant but not met (1.43× vs 3×) |
| 3 | Escalate with a name and reason; approve the write in CoCo | A human decision, recorded append-only |
| 4 | $sar-draft 37 |
FinCEN-structured SAR, every claim cited, "appears consistent with", citation check 4 / 4 |
| 5 | $sar-draft on an un-escalated alert |
Refuses: no SAR without a human review |
| 6 | App → Ask the copilot: "Which 3 high-risk customers deposited the most cash?" (≈ 20 s; pre-ask it before recording or cut the wait) | Agent answer + table + generated SQL |
| 7 | Same question in AML_AUDIT_VIEW |
Same numbers, names *** masked ***, enforced by Snowflake |
| 8 | App → SARs → Approve; Audit tab | Approval gated on citations; decision log with the deciding role; rule metrics |
| Limitation | Why it matters | What we'd do next |
|---|---|---|
Velocity and pass-through thresholds were tuned on data/ |
data/'s own numbers are optimistic |
Done: scored on data_holdout/ (second seed). Pass-through scored again with the original generator settings, since the generator was adjusted for it: 10/13 — see section 8 |
| Synthetic data from one generator | Patterns are cleaner than real transaction streams | Real (de-identified) data, or a second independent generator |
FATF_JURISDICTIONS is the June 2026 FATF list (3 black, 22 grey; cross-checked against two published lists on 2026-10-02) plus the bank's own list |
FATF updates it three times a year; the next plenary is October 2026 | Refresh after each plenary; load the official FATF statement into corpus/ |
| Amounts read as USD although the generator labels them INR | The typologies are sized for US thresholds | Make currency and thresholds a jurisdiction setting (RBI / FIU-IND as a second profile) |
| Append-only holds for working roles, not for account admins | ACCOUNTADMIN can still alter FINDINGS |
Hash-chained findings or an external immutable log; restrict admin use |
| Masking uses secure views (Standard edition) | Anyone with a direct grant on the base tables bypasses it | Enterprise tag-based masking policies |
| AI explanations are constrained, not guaranteed | The model still writes the prose | Facts it must repeat are stored as data; transaction citations are machine-checked; a named human approves every SAR |
| A stale OPEN alert whose pattern stops firing is kept, not closed | Reruns only add new alerts | A RESOLVED_BY_RERUN status once data starts changing under existing alerts |
| Path | What | Owner |
|---|---|---|
README.md |
This document | both |
requirements.txt |
Python 3.11 dependencies for scripts, tests and running the app locally | Pranav |
data/ |
Generated synthetic CSVs + mapping notes (data/README.md) |
Udith |
data_holdout/ |
Second, differently-seeded dataset for honest held-out evaluation (data_holdout/README.md) |
Udith |
data_generator/ |
The generator script that produces data/ and data_holdout/ |
Udith |
corpus/ |
Policy (internal_aml_policy.md / .pdf) + FinCEN + FATF documents for Cortex Search |
Pranav · Udith |
sql/01_load_raw.sql |
Stages + COPY INTO raw tables |
Pranav |
sql/02_canonical.sql |
Raw → data contract | Pranav |
sql/05_reference.sql |
FATF black / grey list (June 2026) + the bank's own high-risk list | Pranav |
sql/10_alerts_and_cycle_proc.sql |
ALERTS table + registers DETECT_ROUND_TRIPS() |
Pranav |
sql/20_detection_rules.sql |
Structuring, velocity, pass-through, geo-risk; ALERT_QUEUE, ALERT_TXNS |
Pranav |
sql/30_evaluate.sql |
RULE_METRICS against ground truth |
Pranav |
sql/40_cortex_search.sql |
Parse, chunk, index the corpus → AML_POLICY_SEARCH |
Pranav |
sql/50_findings.sql |
FINDINGS, SAR_REPORTS, SAR_CITATION_CHECK |
Pranav |
sql/60_semantic_view.sql |
Secure views CUSTOMER_PROFILE, SAR_REVIEW + AML_SEMANTIC_VIEW |
Pranav |
sql/70_agent.sql |
Cortex Agent AML_COPILOT |
Pranav |
sql/80_governance.sql |
Roles, grants, ground-truth isolation, masking | Pranav |
sql/90_streamlit.sql |
Deploys the app twice (officer, auditor) on the warehouse runtime | Pranav |
detection/cycles.py |
Round-trip / layering cycle search (Snowpark handler + self-check) | Pranav |
.cortex/skills/aml-detect/SKILL.md |
CoCo skill: run rules, summarise queue, show metrics | Pranav |
.cortex/skills/aml-investigate/SKILL.md |
CoCo skill: gather evidence, explain with citations, record the decision | Pranav |
.cortex/skills/sar-draft/SKILL.md |
CoCo skill: refuse without escalation, draft a cited SAR, run the citation check | Pranav |
streamlit/streamlit_app.py |
The command centre | Pranav |
streamlit/environment.yml |
Pinned app packages (streamlit 1.39.0, pandas 2.3.3) | Pranav |
scripts/deploy.py |
Uploads files + runs every SQL script in order | Pranav |
scripts/check_rerun_stable.py |
Live: reruns never add, renumber or reset alerts | Pranav |
scripts/check_governance.py |
Live: each role can do exactly what it should (36 checks) | Pranav |
tests/duck.py |
Runs the repo's SQL on DuckDB; shared rerun-stability check | Pranav |
tests/test_rules_duckdb.py |
Every rule on planted typologies | Pranav |
tests/run_on_data.py |
Full pipeline on data/ with metrics |
Pranav |
tests/run_on_holdout.py |
Same pipeline on data_holdout/ (second seed, honest numbers) |
Udith |
tests/test_app.py |
App renders per role, live | Pranav |
tests/test_app_flow.py |
Escalate → SAR → approve through the UI, live; cleans up | Pranav |
tests/test_app_chat.py |
Chat through the UI, live | Pranav |
docs/brief.txt |
Submission brief (≤1024 characters) | both |
| File | Source |
|---|---|
internal_aml_policy.md / .pdf |
Written for this project (fictional bank). The PDF is what gets indexed |
fincen_sar_narrative_guidance.pdf |
FinCEN, Guidance on Preparing a Complete & Sufficient SAR Narrative |
fincen_sar_filing_instructions.pdf |
FinCEN, SAR Electronic Filing Instructions |
fatf_recommendations_2012.pdf |
FATF, International Standards on Combating Money Laundering and the Financing of Terrorism & Proliferation (the 40 Recommendations) |
fatf_high_risk_jurisdictions.md |
FATF, Jurisdictions under Increased Monitoring ("grey list"), 24 Oct 2025 publication. Reference only, not indexed: the detection table uses the newer June 2026 list |
fatf_professional_money_laundering.pdf |
FATF, Professional Money Laundering report |
fatf-gafi.org blocks scripted downloads (confirmed 403 on every direct
fetch attempt, including the live page itself, not just PDFs). The three
FATF files above were retrieved via the Internet Archive's Wayback Machine,
which mirrors the official FATF site's content byte-for-byte — same source,
different retrieval path. See each file / fatf_high_risk_jurisdictions.md's
header for the exact snapshot URL and date.
Submission closes 4 Oct 2026, 11:59 PM IST. Required: public GitHub repo, deployed link, ≤1024-character brief, 3–5 minute demo video recorded in CoCo CLI (input → processing → output, 2–3 modular skills), and a PDF deck (≤5 MB) on the hackathon template.
| Item | Owner | |
|---|---|---|
| ✅ | Data load + canonical mapping on the real generated data | Udith · Pranav |
| ✅ | Five detection rules + cycle search, evaluated against ground truth | Pranav |
| ✅ | Deployed to Snowflake (RISK_COPILOT.AML); metrics match the local run exactly |
Pranav |
| ✅ | Cortex Search over policy + FinCEN docs (119 chunks) | Pranav |
| ✅ | $aml-detect runs end to end in CoCo |
Pranav |
| ✅ | All three skills tested end to end on live alerts (section 9.5) | Pranav |
| ✅ | Semantic view + Cortex Analyst + Cortex Agent, answers verified | Pranav |
| ✅ | Roles, append-only grants, PII masking (secure views), 36 / 36 checks | Pranav |
| ✅ | Streamlit command centre, officer + auditor copies, full flow tested through the UI | Pranav |
| ✅ | Second, differently seeded dataset for held-out evaluation (data_holdout/, seed 20259) |
Udith |
| ✅ | FATF documents into corpus/ (Recommendations, grey list, Professional Money Laundering) |
Udith |
| ✅ | Repo public | Pranav |
| ✅ | FATF list refreshed to the June 2026 plenary | Pranav |
| ⏳ | Deck on the hackathon template, demo video | both |
Thanks to: FinCEN for the public SAR guidance in corpus/; the round-trip detector's idea
comes from Pranav's earlier Arbix project.
Beyond the hackathon: case-management integration, analyst feedback feeding threshold tuning, regulator-format export (FinCEN BSA XML), RBI / FIU-IND STR as a second jurisdiction, ML risk scoring behind the deterministic rules (explainable boosting, never a black box).
Every parameter, as deployed.
| Setting | Value |
|---|---|
| Cloud / region | AWS ap-northeast-1 (Tokyo) |
| Edition | Standard (trial: $40 CoCo + $360 platform credits) |
| Cross-region inference | CORTEX_ENABLED_CROSS_REGION = 'ANY_REGION' (Claude is not hosted in every region) |
| Warehouse | COMPUTE_WH, X-Small, Gen 2, auto-suspend 300 s |
| Database / schema | RISK_COPILOT.AML |
| CoCo CLI | v1.1.87; SQL tool name snowflake_sql_execute |
| Auth | Browser OAuth (CoCo, people); RSA key pair (SNOWFLAKE_JWT) for scripts |
| Credit use to build + test everything | ≈ 9.7 credits (CoCo 6.4, warehouse 1.9, agent 0.9, AI functions 0.5) |
| Item | Value |
|---|---|
| Rules | 5: structuring, velocity, pass-through (SQL); geo-risk (SQL + FATF table); round-trip cycles (Snowpark) |
| Thresholds | See section 4.2 and corpus/internal_aml_policy.md |
| Cycle search | Python 3.11 Snowpark procedure; bounded DFS over time-ordered edges; 2–4 hops; 7-day window; ≥ 80 % conserved per hop; min $10,000 |
| Alert identity | ALERT_KEY = MD5(rule | sorted txn ids); insert only new keys |
| Tie-break | Every QUALIFY ROW_NUMBER() orders by score, then earliest time, then txn id |
| FATF list | June 2026 plenary: 3 black, 22 grey, + 1 from the bank's own list |
| Output | 68 alerts on the generated data |
| Item | Value |
|---|---|
| Service | AML_POLICY_SEARCH, search column CHUNK, attributes SOURCE, SECTION |
| Embedding model | snowflake-arctic-embed-m-v1.5 |
| Scoring | text_match (keyword) + cosine_similarity (vector) → reranker_score (final order) |
| Refresh | TARGET_LAG = '1 day' on COMPUTE_WH |
| Parsing | AI_PARSE_DOCUMENT(..., {'mode': 'LAYOUT'}) |
| Chunking | SPLIT_TEXT_MARKDOWN_HEADER, headers # / ##, 2,000 characters, 300 overlap |
| Corpus | 5 PDFs → 471 chunks (FATF Recommendations 256, FATF PML 96, FinCEN filing 64, FinCEN narrative 44, policy 11); fatf_high_risk_jurisdictions.md is reference only, not indexed (deploy indexes *.pdf; it is the Oct 2025 list, older than FATF_JURISDICTIONS) |
| Part | Count | Contents |
|---|---|---|
| Logical tables | 5 | customers (via secure view CUSTOMER_PROFILE), accounts, activity (ACCOUNT_ACTIVITY), alerts (ALERT_FACTS), findings |
| Relationships | 4 | accounts → customers, activity → accounts, alerts → accounts, findings → alerts |
| Facts | 2 | amount, declared_monthly_income |
| Dimensions | 23 | IDs, names, risk rating, PEP flag, channel, direction, country, date, month, rule, severity, base severity, severity reason, status, policy clause, decision, analyst … |
| Metrics | 9 | total_amount, transaction_count, cash_deposits, high_risk_country_wires, alert_count, open_alerts, critical_alerts, customer_count, decision_count |
| SQL rules | 1 block | USD amounts; "high-risk" = RISK_RATING='HIGH'; cash deposit = CASH + IN; never read TXN_LABELS |
| Item | Value |
|---|---|
| Orchestration model | claude-sonnet-4-5 |
| Budget per question | 60 seconds, 16,000 tokens |
| Tools | AmlData (cortex_analyst_text_to_sql on the semantic view, warehouse COMPUTE_WH); AmlPolicy (cortex_search, max 4 results, title SOURCE, id SECTION) |
| Instructions | Never decide fraud or state guilt; cite every number and rule; call a clause met only with the arithmetic; explain severity only from SEVERITY_REASON |
| Called via | SNOWFLAKE.CORTEX.DATA_AGENT_RUN (non-streaming); answer = text + table content items |
| Latency (measured, incl. connection) | ≈ 20 s for one tool (data or policy); ≈ 48 s when it uses both ("why was alert 37 raised…") |
| Item | Value |
|---|---|
| Model | AI_COMPLETE('claude-sonnet-4-5', …) from the app; CoCo's own model in $sar-draft |
| Input | Alert, SEVERITY_REASON, rule evidence, policy clause text, transactions, KYC, the analyst's escalation |
| Structure | Introduction · Who · What · When · Where · Why · Analyst review (FinCEN) |
| Guards | Refuses without a human escalation; no legal conclusions; no invented timezone; cite or omit |
| Check | SAR_CITATION_CHECK: every TXN_[0-9A-F]{12} must exist and touch the alerted account or be in the alert's evidence; approval disabled otherwise |
| Item | Value |
|---|---|
| Runtime | SYSTEM$WAREHOUSE_RUNTIME (reads environment.yml); Python 3.11 |
| Packages | streamlit=1.39.0, pandas=2.3.3 |
| Copies | AML_COMMAND_CENTRE owned by COMPLIANCE_OFFICER; AML_AUDIT_VIEW owned by AUDITOR |
| Calls | Cortex Search (SEARCH_PREVIEW), Cortex Agent (DATA_AGENT_RUN), AI_COMPLETE, plain SQL with bind parameters |
| Item | Value |
|---|---|
| Roles | AML_ANALYST ⊂ COMPLIANCE_OFFICER; AUDITOR; both under SYSADMIN |
| Masking | Secure views CUSTOMER_PROFILE, SAR_REVIEW keyed on CURRENT_ROLE() |
| Append-only | INSERT without UPDATE / DELETE on FINDINGS, SAR_REPORTS for analysts |
| Rebuild safety | COPY GRANTS on every recreated table / view / semantic view; search service and agent re-granted by 80_governance.sql |
| Verified by | scripts/check_governance.py: 36 checks with secondary roles off |
Synthetic data only. No real customers, accounts or personal data. AI-generated explanations and drafts are decision support; filing decisions rest with named humans.