<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Notes by Nathaniel Pas</title>
    <link>https://www.nathanielpas.com/notes</link>
    <description>Short, first-person notes on fraud engineering, SQL, detection systems, and the things I build on the side.</description>
    <language>en-us</language>
    <atom:link href="https://www.nathanielpas.com/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Explain it to the auditor</title>
      <link>https://www.nathanielpas.com/notes/explain-it-to-the-auditor</link>
      <guid isPermaLink="true">https://www.nathanielpas.com/notes/explain-it-to-the-auditor</guid>
      <pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>AI is great for fraud analysts. It is bad for fraud decisions. Those are different jobs, and most of the confusion I see comes from treating them as one.</p>
<h2>The analyst&#x27;s side</h2>
<p>Give a language model a case file and it will summarize it, put the timeline in order, draft the SAR narrative, and point at the other accounts that share a device with the one you are looking at. That is real leverage. An analyst who spends less time typing up what happened has more time to decide what it means. I use it that way, and I would tell anyone working a queue to do the same.</p>
<h2>The decision&#x27;s side</h2>
<p>Now put the same model in front of an auditor. The customer has disputed a closure. The regulator wants to know why this account was frozen and that one was not. &quot;The model scored it high&quot; is not an answer. Neither is a probability. The person who owns the decision has to be able to say which facts drove it, in what order, and why those facts justify the action taken. They may have to say it under oath.</p>
<p>A model cannot testify. It cannot defend a dispute. It cannot walk an examiner through its reasoning in terms the examiner is required to accept. Whoever signed off on the decision can, but only if the system underneath them explains itself.</p>
<h2>What explaining itself looks like</h2>
<p>Reason codes, mostly. Every decision carries the specific signals that fired, in plain language, with the evidence attached. &quot;Bank fingerprint shared with previously closed accounts.&quot; &quot;Document MRZ checksum failed.&quot; &quot;Device seen on multiple identities inside a day.&quot; The list is finite, it is versioned, and a person can read it without a data science degree.</p>
<p>That constraint is why <a href="https://www.nathanielpas.com/projects">KYC Sentinel Lab</a> returns 36-plus explainable reason codes instead of a score. A score tells you what the system thought. A reason code tells you what the system saw, which is the only thing an auditor, a dispute analyst, or a court will accept. Building the codes is more work than training the classifier. It is also the difference between a decision you can defend and a decision you hope nobody asks about.</p>
<p>Use AI to make the humans faster. Keep a human on the decision. And build the system so that when someone asks why, the answer is already written down.</p>]]></description>
    </item>
    <item>
      <title>Manual review is a budget line, not a strategy</title>
      <link>https://www.nathanielpas.com/notes/manual-review-is-a-budget-line</link>
      <guid isPermaLink="true">https://www.nathanielpas.com/notes/manual-review-is-a-budget-line</guid>
      <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>When fraud volume goes up, the reflex is to add reviewers. It feels responsible. Headcount is visible, it is easy to explain to finance, and every case gets a pair of human eyes.</p>
<p>It is also a way of paying, every month, for a decision you never made.</p>
<h2>What review is for</h2>
<p>Manual review exists for the cases where judgment matters: the ambiguous identity, the customer who looks like a mule but might be a grandparent, the first instance of a pattern nobody has named yet. That work is valuable, and it is why fraud teams need experienced people.</p>
<p>Most of what lands in a review queue is not that. It is the same handful of situations arriving over and over, decided the same way every time, by someone whose expertise is being spent on typing &quot;approve.&quot; When I pulled the decision history on a deposit queue I once owned, most outcomes were predictable from a handful of signals. The analysts already knew that. Nobody had written it down.</p>
<h2>Automate the 80%</h2>
<p>The split I aim for is rough but useful. Roughly 80% of a queue follows rules people already apply in their heads. Transcribe those rules into code, add the guardrails Compliance needs, and let the system decide. The remaining 20% is where the judgment lives. Route it to people, and send the reason along with it so the analyst starts from the anomaly instead of from a blank screen.</p>
<p>That is not a smaller team. It is a different team: fewer people processing, more people investigating, and a queue that stays short even when volume does not. The <a href="https://www.nathanielpas.com/notes/the-first-thing-i-automated">deposit-limit work I wrote about</a> followed exactly that shape.</p>
<h2>What hiring your way out costs</h2>
<p>Reviewers scale linearly with volume. Fraud does not care about your hiring plan. A team built on manual review has a detection ceiling equal to its headcount, and its best people spend their days on the cases that least need them. The budget line grows, the backlog barely moves, and the pattern nobody has noticed yet keeps running because everyone is busy.</p>
<p>Automate the decisions you can explain. Put people on the ones you cannot. Review is a tool, and a tool is not a strategy.</p>]]></description>
    </item>
    <item>
      <title>Your fraud team probably needs another SQL writer</title>
      <link>https://www.nathanielpas.com/notes/another-sql-writer</link>
      <guid isPermaLink="true">https://www.nathanielpas.com/notes/another-sql-writer</guid>
      <pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>Every fraud team I have worked on had plenty of dashboards. Loss rate by week. Alerts by rule. Queue age. Chargebacks by BIN. Someone built each one because someone asked a good question once, and the dashboard has been answering that question ever since.</p>
<p>That is the problem with dashboards. They answer yesterday&#x27;s question. Fraud is whatever the current question is.</p>
<h2>Where the rings actually show up</h2>
<p>Every ring I have found showed up first in a query nobody had asked for. Not in a chart. Accounts that shared a bank fingerprint but nothing else. Deposits that landed minutes apart across identities that were supposed to be strangers. A refund pattern that only looked wrong when you grouped by device instead of by customer. None of those had a tile, because nobody knew to build one until after the query ran.</p>
<p>A dashboard is a frozen query with a nice frame. The moment the pattern shifts, the frame is pointing at the wrong thing, and the person who can re-aim it is the person who can write SQL.</p>
<h2>The budget decision and the hiring decision</h2>
<p>Buying a dashboard tool is a budget decision. You sign, you get charts, and the vendor&#x27;s roadmap decides what you are allowed to ask. Hiring someone who writes queries is a hiring decision. It is slower, it needs a manager who can tell good SQL from bad, and the payoff is a team that can ask a new question on a Tuesday afternoon without filing a ticket.</p>
<p>Most teams pick the budget decision because it is easier to approve. Then they wonder why detection plateaus while the vendor bill grows.</p>
<h2>What I would actually do</h2>
<p>Keep the dashboards. They are good at the boring, repeated questions, and boring repeated questions matter. Then make sure at least one person on the team, ideally more than one, treats the warehouse as their primary tool, and give them the time to go looking. Queue metrics improve when you close cases. Detection improves when you find the thing the queue does not know about yet.</p>
<p>The analyst who can write a query is not a luxury on a fraud team. They are the part of the team that can see.</p>]]></description>
    </item>
    <item>
      <title>The first thing I automated</title>
      <link>https://www.nathanielpas.com/notes/the-first-thing-i-automated</link>
      <guid isPermaLink="true">https://www.nathanielpas.com/notes/the-first-thing-i-automated</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>Every fraud team has a queue that exists because nobody wrote down the decision.</p>
<p>At Binance.US, ours was deposit limit increases. A customer asked for a higher limit, the request went to a person, the person read the account history and said yes or no. The queue grew faster than the team did, funding got delayed, and two analysts looking at the same account could land on different answers. Customers experienced all of that as &quot;the exchange is slow.&quot;</p>
<h2>Reading the decisions we had already made</h2>
<p>Before automating anything I pulled the history of approvals and denials and asked one question: what were we actually checking? The honest answer was a short list. Account age. Verification tier. Deposit and withdrawal history. Whether anything had ever flagged. Most approvals were predictable from that handful of signals. The judgment calls were real, but they were a minority of the queue.</p>
<p>That is the shape you want to find. When most decisions follow rules people already apply in their heads, the automation is not a model. It is a transcription. Stripped down, the logic looked like this:</p>
<pre class="overflow-x-auto rounded-2xl border p-4 text-[0.875em] leading-relaxed border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"><code class="hljs language-python"><span class="hljs-keyword">def</span> <span class="hljs-title function_">decide</span>(<span class="hljs-params">request: LimitRequest</span>) -&gt; Decision:
    account = request.account

    <span class="hljs-keyword">if</span> account.open_flags <span class="hljs-keyword">or</span> account.kyc_tier &lt; REQUIRED_TIER:
        <span class="hljs-keyword">return</span> Decision.review(reason=<span class="hljs-string">&quot;guardrail&quot;</span>)

    within_tier = request.amount &lt;= ceiling_for(account.kyc_tier)
    clean = account.clean_days &gt;= CLEAN_HISTORY_DAYS
    <span class="hljs-keyword">if</span> within_tier <span class="hljs-keyword">and</span> clean:
        <span class="hljs-keyword">return</span> Decision.approve(reason=<span class="hljs-string">&quot;within tier, clean history&quot;</span>)

    <span class="hljs-keyword">return</span> Decision.review(reason=<span class="hljs-string">&quot;outside criteria&quot;</span>)
</code></pre>
<p>Two things mattered more than the code. Compliance helped write the guardrails, so the automation never approved something a person was not allowed to approve. And anything that fell outside the criteria went to a human with the reason attached, so the analyst started from &quot;this tripped the withdrawal-velocity check&quot; instead of from zero. We watched the automated decisions with the same quality checks we ran on people.</p>
<h2>What changed</h2>
<p>Product, Engineering, and Compliance each owned a piece, which is why it took months rather than days. Once it was live the queue emptied fast, and the analysts spent their time on the cases that needed them.</p>
<pre class="overflow-x-auto rounded-2xl border p-4 text-[0.875em] leading-relaxed border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"><code class="hljs language-text">Results  backlog down 67% in three weeks · manual review time down 50% · customer satisfaction up 23% · funding per customer up 31%
</code></pre>
<p>I have automated more interesting things since. This is still the one I point to, because the hard part was not technical. It was getting a room of people to agree on what a yes looks like.</p>]]></description>
    </item>
    <item>
      <title>Mapping a synthetic identity ring with SQL</title>
      <link>https://www.nathanielpas.com/notes/mapping-a-synthetic-identity-ring-with-sql</link>
      <guid isPermaLink="true">https://www.nathanielpas.com/notes/mapping-a-synthetic-identity-ring-with-sql</guid>
      <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<p>The accounts looked fine one at a time. That was the point.</p>
<p>At Binance.US, a handful of new accounts landed in my queue with the same shape: fresh identity, bank account linked through Plaid, verification passed, small deposits, then withdrawals to the same few places. Any single account was defensible. The rules engine agreed, because every rule we had scored one account at a time. Nothing scored the group.</p>
<h2>What the rules could not see</h2>
<p>Synthetic identities do not share identity fields. That is what makes them synthetic. The names, dates of birth, and addresses were all different. The ring was based in Nevada, but the addresses on file were not what gave it away. What the accounts shared was infrastructure: the same bank account fingerprints coming back from verification, the same devices, deposits landing minutes apart, withdrawals fanning out to the same destinations.</p>
<p>So I stopped looking at accounts and started looking at edges. In Snowflake, that is a self-join on the things people reuse when they run a ring at scale. Simplified:</p>
<pre class="overflow-x-auto rounded-2xl border p-4 text-[0.875em] leading-relaxed border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"><code class="hljs language-sql"><span class="hljs-keyword">with</span> links <span class="hljs-keyword">as</span> (
  <span class="hljs-keyword">select</span> a.account_id, b.account_id <span class="hljs-keyword">as</span> linked_id, <span class="hljs-string">&#x27;bank&#x27;</span> <span class="hljs-keyword">as</span> reason
  <span class="hljs-keyword">from</span> accounts a
  <span class="hljs-keyword">join</span> accounts b
    <span class="hljs-keyword">on</span> a.bank_fingerprint <span class="hljs-operator">=</span> b.bank_fingerprint
   <span class="hljs-keyword">and</span> a.account_id <span class="hljs-operator">&lt;</span> b.account_id
  <span class="hljs-keyword">union</span> <span class="hljs-keyword">all</span>
  <span class="hljs-keyword">select</span> s.account_id, t.account_id, <span class="hljs-string">&#x27;device&#x27;</span>
  <span class="hljs-keyword">from</span> sessions s
  <span class="hljs-keyword">join</span> sessions t
    <span class="hljs-keyword">on</span> s.device_id <span class="hljs-operator">=</span> t.device_id
   <span class="hljs-keyword">and</span> s.account_id <span class="hljs-operator">&lt;</span> t.account_id
)
<span class="hljs-keyword">select</span> account_id, <span class="hljs-built_in">count</span>(<span class="hljs-keyword">distinct</span> linked_id) <span class="hljs-keyword">as</span> degree
<span class="hljs-keyword">from</span> links
<span class="hljs-keyword">group</span> <span class="hljs-keyword">by</span> account_id
<span class="hljs-keyword">order</span> <span class="hljs-keyword">by</span> degree <span class="hljs-keyword">desc</span>;
</code></pre>
<p>The first pass took about 48 hours and turned a handful of suspicious accounts into a network of more than 200. The exposure across it came to $3M+. Run the same query the next morning and the network had grown, which told me the ring was still onboarding.</p>
<h2>Closing the gap</h2>
<p>The uncomfortable finding was not the ring. It was that we were treating a passed bank verification as proof of a person. It is proof of a bank account. I wrote up the gap, took it to the vendor and to Risk Engineering, and we turned the linkage logic into detection rules that fired on shared infrastructure rather than on any one account&#x27;s behavior. Legal and Compliance handled the closures. I turned the queries into a playbook so the next analyst would not start from a blank editor.</p>
<pre class="overflow-x-auto rounded-2xl border p-4 text-[0.875em] leading-relaxed border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"><code class="hljs language-text">Results  $3M+ exposure identified · 200+ accounts closed · first map in 48 hours · 15% fewer false positives from the new rules
</code></pre>
<p>The lesson I kept: rules see one account at a time. Rings do not operate one account at a time. If your detection cannot join, it cannot see the thing that matters.</p>]]></description>
    </item>
  </channel>
</rss>
