Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.finwatch.finance/llms.txt

Use this file to discover all available pages before exploring further.

This guide provides an exhaustive exploration of every mechanism available for constructing when clauses in Watch Script rules. Each section includes real-world fraud detection examples, internal behavior details, and common pitfalls. For the quick-reference version of operators and syntax, see the DSL Reference.

Simple Field Comparisons

The most fundamental building block of a condition is a comparison between a field and a value using one of the six comparison operators.

Comparison Operators

OperatorMeaningExample
==Equal tocurrency == "USD"
!=Not equal tostatus != "verified"
>Greater thanamount > 10000
>=Greater than or equal toamount >= 5000
<Less thanamount < 100
<=Less than or equal toamount <= 50000

Numeric Comparisons

When both sides of a comparison can be interpreted as numbers (specifically float64), FinWatch performs a numeric comparison. This is the most common case for the amount field:
// Exact amount check
when amount == 9999.99

// Range check (combine with 'and')
when amount >= 5000
 and amount <= 50000

// Micro-transaction detection (card testing)
when amount < 1.00

String Comparisons

When either side cannot be parsed as a number, FinWatch falls back to string comparison. Only == and != produce meaningful results for strings. Using >, <, >=, or <= on non-numeric strings will return false.
// Exact string match
when currency == "USD"

// Exclude a specific value
when status != "verified"

// Check a metadata string field
when metadata.payment_method == "wire_transfer"
Warning: String comparison is case-sensitive. "USD" does not equal "usd". If you need case-insensitive matching, use the regex operator with (?i).

Boolean Comparisons

The DSL supports true and false as literal values:
when metadata.is_first_transaction == true
when metadata.kyc_verified == false

Accessing Nested Data

Transaction data in FinWatch is a JSON object. Fields can be nested inside the metadata (or meta_data) object. You access nested fields using dot notation.

Top-Level Fields

These fields are directly available on every transaction:
amount              // float64 — the transaction amount
currency            // string  — e.g., "USD", "EUR", "NGN"
source              // string  — source account identifier
destination         // string  — destination account identifier
description         // string  — free-text description
status              // string  — e.g., "pending", "applied", "failed"
timestamp           // string  — RFC3339 timestamp

Metadata Fields (1-Level Nesting)

Your application can send arbitrary metadata with each transaction. Access it with a single dot:
when metadata.destination_country == "IR"
when metadata.mcc == "7995"
when metadata.ip_address == "192.168.1.1"
when metadata.days_since_last_transaction > 90

Deeply Nested Fields (2+ Levels)

If your metadata contains nested objects, chain the dots:
when metadata.device.fingerprint == "abc123"
when metadata.sender.kyc_level == "basic"
when metadata.location.country_code != "US"

How It Works Internally

The interpreter uses a dig() function that splits the field path by . and traverses the JSON tree one level at a time:
// dig("metadata.device.fingerprint") resolves to:
// transaction["metadata"]["device"]["fingerprint"]
If any part of the path does not exist, dig() returns (nil, false) and the condition evaluates to false. This is a deliberate design choice — it prevents rules from crashing on incomplete data.

The Silent Failure Pitfall

This behavior means a typo in a field name will cause a rule to never trigger — and there will be no error message to alert you:
// BUG: "ammount" is a typo. This condition will ALWAYS be false.
when ammount > 10000

// CORRECT:
when amount > 10000
Tip: When a rule isn’t triggering as expected, the first thing to check is field name spelling. Log the actual transaction JSON and verify the exact field paths.

Combining Conditions with and

The and operator requires both conditions to be true for the combined expression to be true.

Syntax

when <condition_1>
 and <condition_2>
 and <condition_3>
You can chain as many and conditions as you need. All must be true for the rule to fire.

Example: Multi-Factor Suspicious Transaction

rule HighValueForeignWireTransfer {
    description "Flags high-value wire transfers to foreign countries."

    when amount > 10000
     and currency != "USD"
     and metadata.payment_method == "wire_transfer"
     and metadata.destination_country != "US"

    then review
         score  0.7
         reason "High-value foreign wire transfer detected"
}
This rule requires all four conditions to be true:
  1. Amount exceeds $10,000
  2. Currency is not USD
  3. Payment method is a wire transfer
  4. Destination country is not US
If any single condition is false, the entire rule does not fire.

Top-Level AND Semantics

At the top level of a rule’s when clause, conditions connected by and are evaluated with AND semantics — meaning all must pass. The interpreter short-circuits: if any condition fails, it stops evaluating and returns false immediately. This short-circuit behavior is important for performance. See the Short-Circuit Evaluation section below.

Combining Conditions with or

The or operator requires at least one condition to be true for the combined expression to be true.

Syntax

when <condition_1>
  or <condition_2>

Example: Late Night Transaction Detection

rule LateNightTransactions {
    description "Detects transactions made late at night."

    when hour_of_day(timestamp) >= 23
      or hour_of_day(timestamp) <= 4

    then review
         score  0.4
         reason "Transaction occurred during late night hours"
}
This rule fires if the transaction happened at 11 PM or later OR at 4 AM or earlier.

The Precedence Pitfall

This is one of the most critical details in the entire DSL:
and and or have equal precedence and are evaluated left-to-right.
There is no implicit grouping that makes and bind tighter than or (unlike most programming languages where && has higher precedence than ||). Consider this rule:
when A or B and C
In most programming languages, this would be interpreted as A or (B and C). In Watch Script, it is interpreted as (A or B) and C — because it’s evaluated strictly left-to-right. This can lead to unexpected behavior. For example:
// INTENDED: Flag if (late night) OR (early morning AND high value)
// ACTUAL:  Flag if (late night OR early morning) AND high value
when hour_of_day(timestamp) >= 23
  or hour_of_day(timestamp) <= 4
 and amount > 5000
The actual behavior is (hour >= 23 OR hour <= 4) AND amount > 5000 — which means a $100 late-night transaction would not trigger the rule, even though you might have intended it to.

How to Handle Complex Logic

Option 1: Restructure your conditions. Place the and conditions before the or:
when amount > 5000
 and hour_of_day(timestamp) >= 23
Option 2: Split into multiple rules. If you need true OR logic between independent patterns, create separate rules:
// Rule 1: Late night + high value
rule LateNightHighValue {
    when hour_of_day(timestamp) >= 23
     and amount > 5000
    then review score 0.5 reason "Late night high-value transaction"
}

// Rule 2: Early morning + high value
rule EarlyMorningHighValue {
    when hour_of_day(timestamp) <= 4
     and amount > 5000
    then review score 0.5 reason "Early morning high-value transaction"
}
This is the recommended approach for complex logic. Each rule is simpler, easier to test, and produces a clear, specific reason.

Set Membership with in

The in operator checks whether a field’s value exists within a given list. This is essential for blacklists, whitelists, and category checks.

Syntax with Inline Arrays

when metadata.mcc in ("7995", "6012", "4829")
Inline arrays use parentheses with comma-separated values. Values can be strings or numbers.

Syntax with Variables

when metadata.destination_country in $sanctioned_countries
Variables (prefixed with $) reference externally-managed lists. This is the recommended approach for lists that change over time. See the Variables and Dynamic Data guide for details.

How It Works Internally

The interpreter converts the field value to a string, then iterates through the list and compares each element as a string:
g := fmt.Sprint(got)   // Convert field value to string
for _, v := range arr {
    if g == fmt.Sprint(v) {  // Compare as strings
        found = true
        break
    }
}
This means in is a string membership check. The number 7995 and the string "7995" will match each other because both are converted to the string "7995" before comparison.

Example: Sanctioned Country Check

rule SanctionedCountryCheck {
    description "Blocks transactions to sanctioned countries."

    when metadata.destination_country in $sanctioned_countries

    then block
         score  1.0
         reason "Destination country is on global sanctions list"
}

Example: High-Risk Merchant Category

rule SuspiciousMCCCheck {
    description "Flags transactions with high-risk merchant category codes."

    when metadata.mcc in ("7995", "6012", "4829", "6211")

    then review
         score  0.4
         reason "Transaction uses a high-risk merchant category code"
}
The MCC codes above correspond to:
  • 7995: Gambling
  • 6012: Financial institutions
  • 4829: Wire transfers
  • 6211: Security brokers

Pattern Matching with regex and not_regex

Regular expression operators let you match patterns in string fields. This is powerful for detecting suspicious keywords in free-text fields like transaction descriptions.

Syntax

when <field> regex "<pattern>"
when <field> not_regex "<pattern>"
  • regex returns true if the pattern matches anywhere in the field value.
  • not_regex returns true if the pattern does not match.

The Pattern Language

FinWatch uses Go’s regexp package, which implements the RE2 syntax. This is a safe subset of regular expressions that guarantees linear-time matching (no catastrophic backtracking).

Case-Insensitive Matching

Use (?i) at the beginning of your pattern for case-insensitive matching:
// Matches "bitcoin", "Bitcoin", "BITCOIN", "bItCoIn", etc.
when description regex "(?i)bitcoin"

Example: Suspicious Description Patterns

rule SuspiciousDescriptionCheck {
    description "Detects suspicious keywords or patterns in transaction descriptions."

    when description regex "(?i)(btc|bitcoin|crypto|wallet|transfer|gift.?card|western.?union)"
     and amount > 1000

    then review
         score  0.2
         reason "Suspicious description pattern."
}
This pattern matches:
  • btc, bitcoin, crypto, wallet, transfer — cryptocurrency and money transfer keywords
  • gift.?card — “giftcard”, “gift card”, “gift-card” (.? matches zero or one character)
  • western.?union — “westernunion”, “western union”, “western-union”

Example: Email Domain Filtering

rule SuspiciousEmailDomain {
    description "Flags transactions from temporary email domains."

    when metadata.email regex "(?i)@(tempmail|guerrillamail|throwaway|mailinator)\\.com$"

    then review
         score  0.3
         reason "Transaction initiated from a temporary email domain"
}

Example: Excluding Known-Good Patterns

rule NonStandardReference {
    description "Flags transactions with non-standard reference formats."

    when reference not_regex "^[A-Z]{3}-[0-9]{6,10}$"
     and amount > 5000

    then alert
         score  0.2
         reason "Transaction reference does not match expected format"
}

Performance Warning

Regular expressions are evaluated at runtime for every transaction. While RE2 guarantees linear-time matching, complex patterns on long strings can still be slow. Guidelines:
  • Keep patterns simple. Prefer alternation (a|b|c) over complex nested groups.
  • Anchor when possible. Use ^ and $ to anchor patterns to the start/end of the string. This allows the engine to fail fast.
  • Avoid .+ on large fields. Patterns like .+something must scan the entire string.
  • Combine with cheap conditions. Use and amount > X to filter out low-value transactions before the regex runs.

Type Coercion

Understanding how FinWatch compares values of different types is essential for writing correct rules.

The Comparison Algorithm

When evaluating a comparison like amount > 10000, the interpreter follows this algorithm:
  1. Try numeric comparison first. If both the field value and the comparison value can be parsed as float64, perform a numeric comparison.
  2. Fall back to string comparison. If either value cannot be parsed as a number, convert both to strings using fmt.Sprint() and compare lexicographically. For string comparisons, only == and != are supported; >, <, >=, <= return false.

Practical Implications

Numbers work as expected:
// Both sides are numeric. Correct behavior.
when amount > 10000    // 15000 > 10000 → true
when amount == 100.50  // 100.5 == 100.5 → true
String numbers can be tricky: If the amount field arrives as a string "15000" (which can happen depending on your JSON serialization), FinWatch will still parse it as a number and compare correctly — because toFloat() handles string-to-float conversion. However, if you compare a string field like currency with >, the result may surprise you:
// String comparison: "EUR" > "USD"?
// Lexicographically: "E" < "U", so this is FALSE.
// This is almost certainly not what you intended.
when currency > "EUR"  // Probably a bug
Tip: Only use == and != for string fields. Use >, <, >=, <= exclusively for numeric fields.

JSON Number Types

When transaction data arrives as JSON, numbers without quotes are parsed as float64 or json.Number. Numbers with quotes are parsed as strings. FinWatch’s toFloat() function handles both:
{ "amount": 1000 }      // → float64, works perfectly
{ "amount": "1000" }     // → string, but toFloat() parses it to 1000.0
{ "amount": "one thousand" } // → string, toFloat() fails, falls back to string comparison

Short-Circuit Evaluation

FinWatch’s interpreter uses short-circuit evaluation for logical operators. This has important implications for both correctness and performance.

How It Works

  • and: If the left condition is false, the right condition is not evaluated. The result is false.
  • or: If the left condition is true, the right condition is not evaluated. The result is true.

Why It Matters for Performance

Aggregate functions (count(), sum(), etc.) are the most expensive operations in FinWatch because they execute SQL queries against DuckDB. Simple field comparisons are nearly free — they just look up a value in a map. By placing cheap conditions before expensive conditions in an and chain, you can avoid running expensive queries on transactions that would fail the cheap check anyway:
// GOOD: Cheap check first. If amount <= 100, the count() never runs.
when amount > 100
 and count(when destination == $current.destination, "PT24H") > 10

// BAD: Expensive count() runs on EVERY transaction, even $1 ones.
when count(when destination == $current.destination, "PT24H") > 10
 and amount > 100
In a high-throughput environment processing thousands of transactions per second, this optimization can reduce DuckDB query load by an order of magnitude.

The Rule of Thumb

Order your and conditions from cheapest to most expensive:
  1. Simple field comparisons (amount > X, currency == "USD") — near zero cost.
  2. Metadata lookups (metadata.country == "US") — very cheap.
  3. Regex matching (description regex "...") — moderate cost.
  4. Time functions (hour_of_day(timestamp) >= 23) — cheap but involves parsing.
  5. Aggregate functions (count(...), sum(...)) — expensive (SQL query).
  6. Previous transaction lookups (previous_transaction(...)) — most expensive (SQL query with joins).

Next Steps

You now have a thorough understanding of every condition mechanism in the Watch Script DSL. Continue your learning with: