Global Sanctions Screening API

One API call. Every major sanctions list. Under 100 milliseconds. Every match tells you exactly how it was found, why it scored the way it did, and what to do about it.

What Is Noble Sight?

Noble Sight is a free global sanctions screening API. One call screens a name against the major global sanctions lists — OFAC, UK, EU, UN, France, Belgium, and Netherlands — using hybrid trigram, phonetic, and AI-powered matching in under 100 milliseconds.

Every match tells you exactly what triggered it: which list, which name form, the character similarity score, and whether phonetics agreed. Provide secondary attributes — date of birth, country, or entity type — and mismatches are flagged with structured reasons. Nothing is hidden behind an opaque number.

Screening results are stored immutably with the list version used, full request and response, and trace ID. Designed for OFAC's 10-year recordkeeping requirement and SOC 2 audit readiness.

Why Noble Sight

CapabilityNoble Sight (Free)Open Source AlternativesPaid APIs ($79–999/mo)
Per-match pathway breakdownEvery match shows source, trigram score, soundex flagSingle opaque scoreBasic score only
AI name variations89,117 pre-computed + real-time deep screenNoneNone
Dismissal reasonsStructured DOB, country, entity type explanationsNoneNone
Audit persistenceFull request/response + OFAC list versionStateless — lost on restartYes
OFAC list version trackingEvery screening tagged with publish IDNoneVaries
Real-time alert deliveryHMAC-signed webhooks with retry + cursor pollingNoneVaries
Internal watchlist screeningSAR subjects, exited customers, 314(a) names — same fuzzy engine, same API callNoneSeparate system
Response time<100ms (benchmarked)Not publishedNot published

Built Different

Noble Sight is a single 18 MB binary. No runtime. No interpreter. No framework. No node_modules. The entire screening engine, API server, and audit system compiles to one static executable running on a read-only filesystem as a non-root user.

Most screening vendors ship 500 MB–1 GB container images packed with runtimes, package managers, and shells — each one an entry point for attackers. Noble Sight ships on a distroless base image with no shell, no package manager, and no utilities. There is nothing to exploit because there is nothing there.

PropertyNoble SightTypical Screening Vendor
Container image18 MB, distroless, no shell500 MB–1 GB, full OS with package manager
RuntimeNone — compiled to native machine codeJVM, Node.js, or Python interpreter
Attack surfaceZero shell utilities, zero writable pathsFull Linux userland
Cold start<1 second15–30 seconds (JVM warmup)
Memory at idle~30 MB300–500 MB (runtime overhead)
DependenciesGo standard library + PostgreSQLHundreds of transitive packages
SQLParameterized queries, no ORMORM-generated queries, abstraction layers

Every dependency is a liability. Every abstraction layer is a place where queries slow down, errors get swallowed, and auditors lose the trail. Noble Sight has fewer moving parts because compliance software should be the most predictable thing in your stack — not the most complex.

Architecture

The screening engine implements the model cascade architecture recommended by Federal Reserve research (FEDS 2025-092). Allen & Hatfield found that LLMs reduce false positives by 92% but are 10,000x slower than fuzzy matching. Their recommendation: fast fuzzy matching for routine screens, LLM escalation for hard cases.

Fast Path
Trigram + Soundex + 89K pre-computed AI variations
<100ms
Deep Path
Real-time Gemini AI name expansion
+1–3s

Noble Sight pre-computes LLM-generated spelling, transliteration, and cultural name variations at import time — amortizing the AI cost once per SDN update, not once per query. This innovation is not discussed in the Fed paper.

Lists Screened

ListSourceStatus
OFAC SDNU.S. Treasury — Specially Designated NationalsLive
OFAC ConsolidatedU.S. Treasury — Non-SDN Consolidated ListLive
UK SanctionsHM Treasury — UK Financial Sanctions (OFSI)Live
EU SanctionsEuropean Commission — EU Consolidated Financial SanctionsLive
UN SanctionsUN Security Council — Consolidated ListLive
FranceDirection Generale du Tresor — Registre National des GelsLive
CanadaGlobal Affairs Canada — SEMA Consolidated ListPlanned
AustraliaDFAT — Australian Consolidated Sanctions ListPlanned
BelgiumBelgian Federal Finance Ministry — National Terrorist ListLive
NetherlandsRijksoverheid — National Terrorism ListLive

Every live list uses the same screening engine — trigram, phonetic, and AI-powered matching. Every list is watched continuously and delta-imported on change. Portfolio monitoring automatically re-screens your entities when any list updates.

Screen a Name in 60 Seconds

Noble Sight is currently in invite-only beta. Contact sales@noblesight.io for an invite code. With a code, the steps below take 60 seconds.

1

Get an API Key

curl -X POST https://noblesight.io/v1/keys \
  -H "Content-Type: application/json" \
  -d '{"client_id": "your-company", "accept_terms": true, "invite_code": "noble_inv_..."}'
2

Screen a Name

curl -X POST https://noblesight.io/v1/screen \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{"name": "Vladimir Putin"}'
3

Get Results

{
  "screened_name": "Vladimir Putin",
  "alert_id": 42,
  "match_count": 1,
  "matches": [{
    "uid": "36095",
    "first_name": "Vladimir",
    "last_name": "PUTIN",
    "score": 97.5,
    "source": "OFAC",
    "programs": ["RUSSIA-EO14024"],
    "date_of_birth": "1952-10-07",
    "place_of_birth": "Leningrad, Russia",
    "nationalities": ["Russia"],
    "match_sources": [{
      "source": "ofac_sdn",
      "trigram_score": 100,
      "soundex_match": true
    }]
  }],
  "meta": {
    "disclaimer": "Noble Sight provides sanctions screening results for informational purposes only. This service is not a substitute for a comprehensive sanctions compliance program. Noble Sight does not provide legal, regulatory, or compliance advice. Screening results reflect data available at the time of the request and may not capture all sanctions designations, aliases, or name variations. Final screening decisions, risk assessments, and compliance obligations remain the sole responsibility of the subscribing institution. Use of this service does not satisfy or replace any obligation under OFAC regulations, the Bank Secrecy Act, or any other applicable law."
  }
}

Workflow Playbook

The API reference tells you what each endpoint does. This guide shows you how to use them together. Eight workflows, step by step, with copy-paste curl examples.

1. Onboard a Customer

A new customer applies. You need to screen their name against sanctions lists before opening the account. Provide every attribute you have — name, date of birth, country, entity type, and government ID — to get the most accurate score with the fewest false positives.

1

Screen with full attributes

Pass everything you collected during KYC. Each attribute reduces false positives — a DOB mismatch alone can drop a 96 to a 38.

curl -X POST https://noblesight.io/v1/screen \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -H "X-Trace-ID: onboarding-acct-7891" \
  -d '{
    "name": "Alexander Volkov",
    "date_of_birth": "1985-03-15",
    "country": "RU",
    "entity_type": "individual",
    "id_number": "P1234567",
    "id_type": "Passport"
  }'
2

Read the response

Check match_count and alert_id. If match_count is 0, the customer is clear. If an alert_id is present, a match exceeded your alert threshold and needs review.

{
  "screening_id": 5678,
  "alert_id": 0,
  "screened_name": "Alexander Volkov",
  "match_count": 0,
  "average_score": 0,
  "lists_screened": ["ofac", "uk", "eu", "france", "belgium", "netherlands", "un"],
  "matches": [],
  "meta": { "disclaimer": "..." }
}

No matches, no alert. Proceed with account opening. The screening is stored immutably with your trace ID for audit.

3

If there is a match

When the response includes an alert_id, a match needs investigation. Check the dismissal_reasons array — it tells you exactly which attributes mismatched and by how much. Then move to the alert triage workflow below.

{
  "screening_id": 5679,
  "alert_id": 44,
  "screened_name": "Alexander Volkov",
  "match_count": 1,
  "matches": [{
    "uid": "12345",
    "first_name": "Aleksandr",
    "last_name": "VOLKOV",
    "score": 42.3,
    "source": "OFAC",
    "programs": ["RUSSIA-EO14024"],
    "match_sources": [{"source": "ofac_sdn", "trigram_score": 84.5, "soundex_match": true}],
    "dismissal_reasons": [
      "DOB mismatch: provided 1985-03-15, SDN has 1960-07-22 (far mismatch, 0.4x)",
      "Country matches: RU"
    ],
    "date_of_birth": "1960-07-22",
    "nationalities": ["Russia"]
  }]
}

The raw trigram score was 84.5, but the 25-year DOB mismatch applied a 0.4x penalty, dropping the final score to 42.3. The dismissal_reasons array explains exactly why.

2. Triage and Resolve Alerts

A screening returned a match. Someone needs to review it, decide if it is real, and document the decision. This is the full alert lifecycle — from new alert through investigation to resolution. Every action is recorded immutably.

1

List new alerts

Pull all unreviewed alerts. Filter by score to prioritize the highest-confidence matches first.

curl "https://noblesight.io/v1/alerts?status=new&min_score=80&limit=25" \
  -H "X-API-Key: $NOBLE_API_KEY"
2

Assign to an analyst

SLA deadlines are set automatically: 24 hours for scores 90+, 72 hours for all others.

curl -X POST "https://noblesight.io/v1/alerts/44/assign" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{"assign_to": "analyst@yourcompany.com"}'
3

Investigate and add notes

Pull the full alert detail, compare secondary identifiers, and document your findings. Notes are immutable — they become part of the audit trail.

# Get full alert detail
curl "https://noblesight.io/v1/alerts/44" \
  -H "X-API-Key: $NOBLE_API_KEY"

# Add an investigation note
curl -X POST "https://noblesight.io/v1/alerts/44/notes" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{"note": "Checked passport against OFAC records. DOB differs by 25 years. Different person."}'
4

Resolve as false positive

Close the alert with a structured reason code. Examiners can aggregate these across your entire history to verify your process is systematic.

curl -X POST "https://noblesight.io/v1/alerts/44/resolve" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "status": "closed_false_positive",
    "reason": "false_positive_dob_mismatch",
    "note": "SDN DOB is 1960, customer DOB is 1985. 25-year gap. Not the same individual."
  }'

Reason codes: false_positive_name_only, false_positive_dob_mismatch, false_positive_country_mismatch, false_positive_entity_type_mismatch, false_positive_other, true_match.

3. Handle a True Match

Your analyst confirmed a sanctions match. What happens next depends on what your institution did — block the transaction, reject a wire, or permit it under a General License. Each disposition carries a different OFAC reporting obligation and deadline.

1

Resolve as true match with disposition

Choose the disposition that matches your institution's action. blocked and rejected require filing with OFAC within 10 business days.

curl -X POST "https://noblesight.io/v1/alerts/42/resolve" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "status": "closed_true_match",
    "reason": "true_match",
    "disposition": "blocked",
    "note": "Wire transfer blocked. Customer name and nationality confirmed against SDN entry 36095."
  }'
2

Pull the OFAC report

For blocked and rejected dispositions, this endpoint assembles the evidence your compliance team needs to file with OFAC. Fill in the institution_fields (transaction description and value) before submitting.

curl "https://noblesight.io/v1/alerts/42/ofac-report" \
  -H "X-API-Key: $NOBLE_API_KEY"
{
  "alert_id": 42,
  "report_type": "blocking",
  "generated_at": "2026-03-10T14:30:00Z",
  "screened_name": "Vladimir Putin",
  "sdn_match": {
    "uid": "36095",
    "first_name": "Vladimir",
    "last_name": "PUTIN",
    "programs": ["RUSSIA-EO14024"],
    "score": 97.5
  },
  "screening": {
    "screened_at": "2026-03-09T10:15:00Z",
    "ofac_publish_id": "1234",
    "ofac_publish_date": "2026-03-08",
    "trace_id": "onboarding-customer-42"
  },
  "resolution": {
    "status": "closed_true_match",
    "disposition": "blocked",
    "reason": "true_match",
    "resolved_by": "analyst@yourcompany.com",
    "resolved_at": "2026-03-10T14:00:00Z"
  },
  "institution_fields": {
    "transaction_description": null,
    "transaction_value": null,
    "instructions": "Complete transaction_description and transaction_value before filing with OFAC."
  }
}
3

Know your deadlines

DispositionOFAC ObligationDeadline
blockedFile a blocking report10 business days
rejectedFile a reject report10 business days
permitted_glDocument the General License citationImmediately
no_action_requiredMaintain internal documentationImmediately

4. Screen a Batch

Onboard a portfolio of customers, run a periodic re-screen, or process a file from your core banking system. Submit up to 10,000 names, poll for completion, review results. Standard tier and above.

1

Submit the batch

Each name can include its own secondary attributes. The response returns a batch ID for polling.

curl -X POST https://noblesight.io/v1/batch \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "names": [
      {"full_name": "Vladimir Putin", "date_of_birth": "1952-10-07", "country": "RU"},
      {"full_name": "Ali Khamenei", "country": "IR"},
      {"full_name": "Jane Smith", "country": "US"}
    ]
  }'
{
  "id": 1234,
  "status": "pending",
  "total_names": 3,
  "processed_names": 0,
  "match_count": 0
}
2

Poll for completion

Check status until it reaches completed. Per-name results include match counts, highest scores, and alert IDs.

curl "https://noblesight.io/v1/batch/status?id=1234" \
  -H "X-API-Key: $NOBLE_API_KEY"
{
  "id": 1234,
  "status": "completed",
  "total_names": 3,
  "processed_names": 3,
  "match_count": 2,
  "items": [
    {"full_name": "Vladimir Putin", "status": "screened", "match_count": 1, "highest_score": 97.5, "alert_id": 42},
    {"full_name": "Ali Khamenei", "status": "screened", "match_count": 1, "highest_score": 94.2, "alert_id": 43},
    {"full_name": "Jane Smith", "status": "screened", "match_count": 0, "highest_score": 0}
  ]
}
3

Triage the alerts

Names that generated alerts need review. Use the alert triage workflow for each one, or resolve low-confidence matches in bulk:

curl -X POST "https://noblesight.io/v1/alerts/bulk/resolve" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "alert_ids": [43],
    "status": "closed_false_positive",
    "reason": "false_positive_name_only"
  }'

5. Set Up Portfolio Monitoring

Onboarding screens a customer once. Portfolio monitoring screens them continuously — whenever sanctions lists change, Noble Sight automatically re-screens your enrolled entities using reverse screening. Add-on for any paid tier.

1

Add a customer to your portfolio

The entity is screened immediately, then re-screened automatically whenever any sanctions list updates.

curl -X POST https://noblesight.io/v1/portfolio \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "full_name": "Dmitry Medvedev",
    "date_of_birth": "1965-09-14",
    "country": "RU"
  }'
2

What happens when lists update

Noble Sight detects the delta — new, modified, or removed sanctions entries. Changed entries are screened against your entire portfolio. Matches generate alerts with source: "monitoring". You receive them the same way you receive any other alert: via webhooks or polling.

3

Review your portfolio

Check which entities are enrolled, their latest scores, and when they were last screened.

curl "https://noblesight.io/v1/portfolio?limit=25" \
  -H "X-API-Key: $NOBLE_API_KEY"
4

Remove an entity when the relationship ends

Historical screening records are retained for audit. Only future re-screening stops.

curl -X DELETE "https://noblesight.io/v1/portfolio?full_name=Dmitry+Medvedev" \
  -H "X-API-Key: $NOBLE_API_KEY"

6. Manage Internal Watchlists

Government lists tell you who regulators have designated. Internal watchlists tell you who your institution has flagged — SAR subjects, exited customers, 314(a) names. Noble Sight screens against both in the same API call. Standard tier and above.

1

Add a SAR subject

The name is available for screening immediately. Phonetic codes are computed at insert time.

curl -X POST https://noblesight.io/v1/watchlist \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "full_name": "John Doe",
    "list_name": "SAR Subjects",
    "reason": "SAR filed 2026-03-01, case #2026-0042",
    "entity_type": "individual",
    "country": "US"
  }'
2

Screen as normal — watchlist matches appear automatically

Every POST /v1/screen call checks your watchlist alongside all government lists. No opt-in flag, no separate call. Matches appear with source: "Internal Watchlist".

curl -X POST https://noblesight.io/v1/screen \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{"name": "Jon Doe"}'

# Response includes:
# "source": "Internal Watchlist"
# "match_sources": [{"source": "internal_watchlist", "trigram_score": 88.5, "soundex_match": true}]
# "remarks": "[SAR Subjects] SAR filed 2026-03-01, case #2026-0042"
3

Add a time-limited entry

Use expires_at for entries that should stop matching after a specific date — FinCEN 314(a) requests, time-limited law enforcement inquiries.

curl -X POST https://noblesight.io/v1/watchlist \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "full_name": "Jane Roe",
    "list_name": "314a Names",
    "reason": "FinCEN 314(a) request dated 2026-03-15",
    "expires_at": "2026-09-15T00:00:00Z"
  }'

7. Set Up Webhooks

Without webhooks, your system polls for new alerts. With webhooks, Noble Sight pushes them to you the moment they are created — signed with HMAC-SHA256 so you can verify they came from us. Standard tier and above.

1

Register your endpoint

Provide a shared secret (minimum 32 characters) for signature verification. The secret is encrypted at rest — Noble Sight never stores it in plaintext.

curl -X POST https://noblesight.io/v1/webhooks \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "url": "https://your-app.com/webhooks/noble",
    "secret": "your-shared-secret-minimum-32-characters-long"
  }'
2

Verify the signature in your handler

Every delivery includes X-Noble-Signature and X-Noble-Timestamp headers. The signature is HMAC-SHA256 over timestamp + "." + body. Verify the signature, then reject deliveries where the timestamp is more than 5 minutes old to prevent replay attacks.

# Go:
timestamp := r.Header.Get("X-Noble-Timestamp")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) { reject }
// Reject if timestamp is more than 5 minutes old
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if time.Since(time.Unix(ts, 0)) > 5*time.Minute { reject }

# Node.js:
const timestamp = req.headers["x-noble-timestamp"];
const expected = "sha256=" + crypto.createHmac("sha256", secret)
  .update(timestamp + "." + body).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) { reject }
if (Date.now()/1000 - parseInt(timestamp) > 300) { reject }
3

Process the event

The payload contains the alert ID, screened name, score, and source. Use the alert ID to fetch full details or route to your case management system.

{
  "id": "evt_a1b2c3d4e5f6g7h8",
  "type": "alert.created",
  "created_at": "2026-03-23T10:05:00Z",
  "data": {
    "alert_id": 42,
    "screened_name": "Vladimir Putin",
    "score": 97.5,
    "source": "api",
    "sdn_uid": "36095"
  }
}

Respond with any 2xx within 10 seconds. Failed deliveries are retried on a durable backoff schedule spanning roughly 10 hours (up to 8 passes), and retry state survives server restarts. Every attempt carries the same X-Noble-Event-ID, so you can deduplicate. A webhook failure never suppresses an alert — it still appears in GET /v1/alerts.

8. Prepare for an Examination

An OFAC examiner asks for your screening records and alert decisions for the last quarter. Every screening and every alert action is stored immutably. Here is how to pull the evidence.

1

Export screening records

Bulk export returns the full request, full response, list version, and trace ID for every screening in the date range.

curl "https://noblesight.io/v1/export/bulk?from=2026-01-01&to=2026-03-31" \
  -H "X-API-Key: $NOBLE_API_KEY"
2

Export alert activity

The case activity log shows every status change, assignment, note, and resolution with full attribution and timestamps. This is the audit trail examiners expect to see.

curl "https://noblesight.io/v1/alerts/activity?from=2026-01-01&to=2026-03-31&limit=500" \
  -H "X-API-Key: $NOBLE_API_KEY"
3

Pull a specific screening by trace ID

If the examiner asks about a specific transaction, use the trace ID you attached during screening to pull the exact record.

curl "https://noblesight.io/v1/export?trace_id=onboarding-acct-7891" \
  -H "X-API-Key: $NOBLE_API_KEY"
4

What examiners look for

Examiner QuestionWhere the Answer Lives
"What list were you screening against on March 1?"ofac_list_version in the screening record
"Who reviewed this alert and what did they decide?"Alert activity log — actor, action, reason, timestamp
"How quickly are alerts resolved?"SLA deadlines + due_at vs. resolution timestamp
"What percentage of alerts are false positives?"Aggregate reason codes across alert resolutions
"Can you produce the screening that triggered this blocking report?"GET /v1/export?trace_id=...

API Reference

The API contract is the code. This is api/v1/api.go — the sole source of truth for request types, response types, error codes, validation constants, and the alert state machine. Embedded at compile time.

// Package v1 defines the public REST API contract for Noble.
// This file is the source of truth for the v1 API.
// Update this file first, then implement handlers in server.go.
//
// # API versioning
//
// The wire API has two version axes. The major axis is the URL path (/v1) —
// reserved for a wholesale redesign and changed almost never. The minor axis is
// the dated Noble-Version request header (see version.go), which is how routine
// breaking changes to response shapes are managed:
//
//   - Clients pin a version: `Noble-Version: 2026-06-01` (a release date, not a
//     SemVer — SemVer is for the installable noble CLI/SDK artifacts, not the
//     wire). Version dates are the breaking-change dates from the changelog
//     (GET /v1/changelog); additive changelog entries do not mint a version.
//     The resolved version is echoed in the response header and recorded in the
//     audit trail.
//   - No header pins to the latest version, so existing callers keep working.
//     An unrecognized version is rejected: 400 invalid_version.
//   - Backward-compatibility policy: additive changes (new response fields, new
//     endpoints, new optional request fields) do NOT bump the version. Only a
//     change that alters or removes existing response shape mints a new dated
//     version; clients pinned to an older date continue to receive the old
//     shape via a response transform applied at that point.
//
// Today exactly one version exists (VersionLatest), so no transform layer is
// built yet — the header, validation, and contract are in place so the first
// breaking change has a home.
package v1

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"
)

// =============================================================================
// POST /v1/screen - Screen a name against sanctions lists
// =============================================================================

// ScreenRequest is the input for screening a name against sanctions lists.
type ScreenRequest struct {
	// Name to screen against sanctions lists. Required unless at least one
	// identifier (IDNumber or IDs) is supplied — an identifier-only request
	// (e.g. a digital-currency wallet address) is screened by exact ID match
	// with no name. A name that is itself an identifier (a pasted wallet
	// address) is additionally exact-matched, so it is caught even when not
	// labelled as an id. Whitespace is trimmed; a whitespace-only name is
	// rejected. When neither a name nor an identifier is present the request
	// is rejected with code query_required.
	Name string `json:"name"`

	// Maximum number of matches to return (default: 10, max: 100)
	Limit *int `json:"limit,omitempty"`

	// DeepScreen enables real-time AI expansion of the search input.
	// When true, the server calls Gemini to generate spelling/cultural
	// variations of the input name and screens each one. Adds ~1-3s latency.
	DeepScreen bool `json:"deep_screen,omitempty"`

	// EntityType of the subject being screened: "individual", "entity", "vessel", "aircraft".
	// When provided, matches against a different entity type are flagged with a dismissal reason.
	EntityType string `json:"entity_type,omitempty"`

	// DateOfBirth of the person being screened, in YYYY-MM-DD format.
	// When provided, matches are penalized if the OFAC entity's DOB
	// differs significantly.
	DateOfBirth string `json:"date_of_birth,omitempty"`

	// Country of residence (ISO 3166-1 alpha-2, e.g., "US", "RU", "CN").
	// When provided, matches are penalized if the OFAC entity's nationality
	// does not match.
	Country string `json:"country,omitempty"`

	// Address of the subject being screened (street, city, postal code).
	// When provided, surfaced alongside the SDN's address data for manual comparison.
	Address string `json:"address,omitempty"`

	// IDNumber is any identifier published on a sanctions list: a government ID
	// (passport, tax ID, cedula, national ID), a financial routing code
	// (SWIFT/BIC), a corporate registration number, a digital currency (crypto
	// wallet) address, or a vessel IMO number. When provided, actively matched
	// against the ID tables of every list in scope (OFAC, UK, EU, France,
	// Belgium, UN, Canada, Australia) using exact match after normalization —
	// both the input and the stored value are uppercased with spaces, dashes,
	// dots, and slashes stripped, so formatting differences never cause a miss.
	// A vessel IMO number matches whether written "IMO 9102198" (OFAC's
	// published form) or as the bare number "9102198".
	IDNumber string `json:"id_number,omitempty"`

	// IDType filters ID matching to a single sanctions-list ID category.
	// Optional; when omitted, the IDNumber is searched against every type.
	//
	// Common values (use the exact string as published by the source list):
	//
	//   Government IDs:
	//     "Passport", "National ID", "Tax ID", "Cedula No.", "Driver's License",
	//     "Birth Certificate Number"
	//
	//   Financial routing:
	//     "SWIFT/BIC"            — bank/financial institution identifier
	//
	//   Corporate:
	//     "Business Registration Number", "Certificate of Incorporation Number"
	//
	//   Digital currency (OFAC SDN):
	//     "Digital Currency Address - XBT"    (Bitcoin)
	//     "Digital Currency Address - ETH"    (Ethereum)
	//     "Digital Currency Address - USDT"   (Tether)
	//     "Digital Currency Address - USDC"   (USD Coin)
	//     "Digital Currency Address - XMR"    (Monero)
	//     "Digital Currency Address - SOL"    (Solana)
	//     "Digital Currency Address - TRX"    (Tron)
	//     "Digital Currency Address - XRP"    (Ripple)
	//     "Digital Currency Address - LTC"    (Litecoin)
	//     "Digital Currency Address - BCH"    (Bitcoin Cash)
	//     plus ARB, BSC, BSV, BTG, DASH, ETC, XVG, ZEC.
	//
	// The full list of in-use idType strings is the union of every <idType>
	// element across the publishers' source files (e.g., OFAC's sdn.xml and
	// consolidated.xml).
	IDType string `json:"id_type,omitempty"`

	// IDs is an additional set of identifiers to screen alongside IDNumber.
	// A single person or entity often carries several — passport plus tax ID
	// plus a crypto wallet plus a SWIFT/BIC. Submitting them in one request
	// avoids one round-trip per identifier and produces a single merged
	// response with one audit row.
	//
	// Each entry is screened independently. The result set is the union of
	// every ID's hits, deduplicated by sanctions-list UID; on hit, the
	// corresponding ScreenMatch.MatchedVia field names which submitted
	// IDs triggered the match.
	//
	// IDNumber/IDType (singular) and IDs (plural) compose — if both are
	// present, the singular is screened in addition to every entry in IDs.
	// Capped at MaxIDsPerRequest entries; entries with an empty Number are
	// rejected with code "invalid_parameter".
	IDs []IDInput `json:"ids,omitempty"`

	// Lists restricts screening to the specified sanctions lists.
	// Valid values: "ofac", "uk", "eu", "france", "belgium", "netherlands", "un", "canada", "australia".
	// When omitted or empty, all lists are screened (default behavior).
	Lists []string `json:"lists,omitempty"`

	// Include opts into response fields that are excluded by default.
	// Reserved for future per-field opt-ins. Unknown values are ignored —
	// additive evolution without breaking clients. The value "summary" is
	// accepted for backwards compatibility but is now a no-op: ScreenSummary
	// is populated by default on every response.
	Include []string `json:"include,omitempty"`

	// DeepSummary, when true, generates the summary text with an LLM (Gemini)
	// instead of the deterministic template. The verdict bucket itself
	// remains deterministic — the LLM only writes prose. Requires Standard
	// tier or higher. Adds ~1s latency.
	DeepSummary bool `json:"deep_summary,omitempty"`

	// AsOf runs the screening against the sanctions lists as they stood at a
	// historical instant — point-in-time screening for audit and examination
	// support ("what would this name have returned on date X?"). Accepts an
	// RFC 3339 timestamp ("2026-07-01T15:04:05Z") for an exact instant, or a
	// bare date ("2026-07-01"), which means end of that day UTC — "the list
	// as it stood on July 1" includes July 1's updates. Today's date is
	// accepted and resolves to the current instant. Anything later is a 400
	// invalid_parameter: as_of must not be in the future.
	//
	// An as-of screening is an inquiry into historical list state, not a
	// present-day compliance decision: it is persisted to the audit trail
	// (with as_of recorded in the stored request) but never creates an alert,
	// never enrolls the name in portfolio monitoring, and never dispatches a
	// webhook. The response's ListVersions reports the snapshots that were
	// active at the requested instant, and ScreenedAsOf echoes the resolved
	// instant. A list with no data imported by the as-of instant is omitted
	// from ListsScreened/ListVersions, exactly as a not-yet-ingested list is
	// today.
	//
	// Known fidelity boundary: OFAC's secondary tables (aliases, addresses,
	// identifiers, DOB/POB) are maintained at current state only, so the
	// OFAC-pathway alias/ID matching and match detail reflect today's child
	// records even under as_of; OFAC primary names and all other lists'
	// records (including their aliases and identifiers) are fully historical.
	// The authoritative historical record of OFAC child data is the changelog
	// (GET /v1/changelog) and the archived list snapshots.
	AsOf string `json:"as_of,omitempty"`
}

// IDInput is a single identifier in ScreenRequest.IDs. Type is optional and
// follows the same string-set documented on ScreenRequest.IDType (Passport,
// SWIFT/BIC, "Digital Currency Address - XBT", etc.). When Type is empty,
// the Number is matched against every ID type the publisher recorded.
type IDInput struct {
	// Number is the identifier to screen for (e.g., passport number, SWIFT/BIC,
	// crypto wallet address). Required. Same normalization as IDNumber:
	// uppercased with spaces, dashes, dots, and slashes stripped before
	// comparison.
	Number string `json:"number"`

	// Type restricts matching to a single sanctions-list ID category.
	// Optional; when omitted, Number is searched against every type.
	Type string `json:"type,omitempty"`
}

// ScreenResponse is the response from a screening request.
type ScreenResponse struct {
	// TraceID is the audit key this screening is persisted under. It echoes the
	// caller's X-Trace-ID, or a server-generated value when none was supplied —
	// the screening is always retrievable. Feed it straight into
	// GET /v1/export?trace_id= to pull the full audit record. Also returned in
	// the X-Trace-ID response header.
	TraceID string `json:"trace_id"`

	// RequestID is the per-call HTTP request ID for this screening — the same
	// value carried in the X-Request-Id response header and in the request_id
	// field of error bodies. Distinct from TraceID: a trace can span many
	// calls, each with its own RequestID. Persisted alongside TraceID so an
	// operator can pivot between the two; see GET /v1/export?request_id=.
	RequestID string `json:"request_id,omitempty"`

	// Unique screening record ID for audit cross-referencing
	ScreeningID int64 `json:"screening_id,omitempty"`

	// Alert ID created for this screening, if the highest match score exceeded
	// the tenant's alert threshold. Zero/omitted means no alert was generated.
	// When a recent duplicate alert already exists, its ID is returned instead
	// of creating a new one (idempotent behavior).
	AlertID int64 `json:"alert_id,omitempty"`

	// OwnershipAlertID is the alert created because this screening's
	// ownership determination came back "blocked" (see Ownership). Distinct
	// from AlertID: a name-match alert and an ownership alert are separate
	// compliance events, and either can fire without the other. Ownership
	// alerts are not gated on the tenant's score threshold — a 50 Percent
	// Rule determination is categorical, not a fuzzy-match score. Dedup is
	// idempotent like AlertID.
	OwnershipAlertID int64 `json:"ownership_alert_id,omitempty"`

	// DeepScreenFallbackReason is populated only when the caller requested
	// deep_screen and the AI variation expansion could not run in full — the
	// response then falls short of the enhanced recall that was requested. A
	// paid feature never degrades silently (same honesty contract as
	// ScreenSummary.FallbackReason); value is one of the FallbackReason*
	// constants: llm_failure means the expansion did not run at all and the
	// results are standard recall; variation_screening_failure means the
	// expansion ran but covered only some generated variations — see
	// DeepScreenCoverage for the exact counts. A deployment with deep_screen
	// entirely unconfigured rejects the request with 503
	// deep_screen_unavailable instead — see CodeDeepScreenUnavailable.
	DeepScreenFallbackReason string `json:"deep_screen_fallback_reason,omitempty"`

	// DeepScreenCoverage reports how much of the requested AI variation
	// expansion actually ran. Present whenever deep_screen was requested and
	// the variation generation succeeded (including when zero variations were
	// generated); nil when deep_screen was not requested or the generation
	// call itself failed (DeepScreenFallbackReason: llm_failure).
	//
	// Invariant: on a deep_screen=true response, either DeepScreenCoverage
	// shows VariationsScreened == VariationsGenerated or
	// DeepScreenFallbackReason is non-empty. Callers can rely on this to
	// detect partial enhanced-recall delivery without parsing logs.
	DeepScreenCoverage *DeepScreenCoverage `json:"deep_screen_coverage,omitempty"`

	// The name that was screened
	ScreenedName string `json:"screened_name"`

	// When this screening was performed (RFC 3339)
	ScreenedAt string `json:"screened_at"`

	// ScreenedAsOf is the historical instant this screening was evaluated
	// against (RFC 3339), present only when the request carried as_of. The
	// matches and list_versions then reflect the sanctions lists as they
	// stood at this instant, not at ScreenedAt.
	ScreenedAsOf string `json:"screened_as_of,omitempty"`

	// Number of matches returned
	MatchCount int `json:"match_count"`

	// Mean score across all matches in this screening (0-100).
	// Useful for portfolio risk assessment. Zero when no matches.
	AverageScore float64 `json:"average_score"`

	// ListsScreened reports which sanctions lists were included in this screening.
	// Always populated, even when the caller did not specify a lists filter.
	ListsScreened []string `json:"lists_screened"`

	// Matched sanctions entries, ranked by score
	Matches []ScreenMatch `json:"matches"`

	// ListVersions reports the publisher snapshot active at screening time
	// for every list named in ListsScreened — one entry per list with
	// imported data. Examiners use this to pin a screening decision to the
	// exact list snapshots that produced it.
	ListVersions []ListVersion `json:"list_versions,omitempty"`

	// OFACListVersion is the OFAC entry from ListVersions, retained as a
	// convenience field.
	//
	// Deprecated: use ListVersions for full multi-list coverage. New
	// integrations should read ListVersions and filter for List == "ofac".
	// Removal scheduled after the 90-day deprecation window.
	OFACListVersion *OFACListInfo `json:"ofac_list_version,omitempty"`

	// Summary is an executive synthesis of the screening result: verdict
	// bucket, top match, distinct lists, strong-match count, and a one-line
	// prose synthesis. Populated by default on every response — the
	// deterministic path is pure-function with no I/O cost. Callers that
	// want LLM-rewritten prose pass DeepSummary=true (paid tier).
	Summary *ScreenSummary `json:"summary,omitempty"`

	// JurisdictionRisk is present when the request's country field places the
	// subject in a jurisdiction under a comprehensive OFAC embargo — Cuba,
	// Iran, North Korea, or an embargoed region of Ukraine (Crimea,
	// Sevastopol, and the so-called DNR/LNR covered regions). This is nexus
	// detection, not list matching: it fires even when matches is empty,
	// because the request itself disclosed the embargo nexus. Absent for all
	// other countries and when no country was supplied. The jurisdiction set
	// tracks current OFAC programs: Syria was removed when its comprehensive
	// program was revoked effective July 1, 2025 (E.O. 14312).
	JurisdictionRisk *JurisdictionRisk `json:"jurisdiction_risk,omitempty"`

	// Ownership is the OFAC 50 Percent Rule determination for the screened
	// name: whether the ownership graph shows it to be 50%-or-more owned by
	// sanctioned parties, with the full evidence chains (see OwnershipMatch).
	// This is how a company that appears on no sanctions list — but is owned
	// by listed parties — surfaces in a screening. Present whenever a name
	// was screened live on a deployment with ownership screening enabled;
	// absent on identifier-only requests and as_of screenings. Advisory
	// plus alerting: a "blocked" determination raises an alert (see
	// OwnershipAlertID) but never alters match scores or the summary verdict.
	Ownership *OwnershipMatch `json:"ownership,omitempty"`

	// Response metadata including legal disclaimer
	Meta ResponseMeta `json:"meta"`
}

// DeepScreenCoverage quantifies a deep_screen expansion: how many AI name
// variations were generated and how many of them were actually screened
// against the sanctions lists. VariationsScreened < VariationsGenerated means
// the enhanced recall is incomplete — the response also carries
// deep_screen_fallback_reason: variation_screening_failure so the shortfall
// is never silent. Equal counts confirm the full expansion was delivered.
type DeepScreenCoverage struct {
	// VariationsGenerated is the number of name variations the LLM produced
	// for this request.
	VariationsGenerated int `json:"variations_generated"`

	// VariationsScreened is the number of generated variations that were
	// screened against the sanctions lists.
	VariationsScreened int `json:"variations_screened"`
}

// JurisdictionRisk is an advisory signal that the screened subject's
// geographic input touches a comprehensive OFAC embargo. It is deliberately
// advisory — enhanced due diligence, never an auto-block: the licensing and
// exemption determination belongs to the institution's compliance program.
// It does not alter match scores or the summary verdict.
//
// Detection accepts ISO 3166-1 alpha-2 codes (the documented country
// format), ISO 3166-2 Ukrainian region codes ("UA-43" Crimea, "UA-40"
// Sevastopol, "UA-14" Donetsk, "UA-09" Luhansk), and common English
// jurisdiction names. Ukraine ("UA") and Russia ("RU") themselves never
// fire — only the embargoed regions do.
//
// Identical detection runs on /v1/screen and per-item on /v1/batch, and the
// signal is persisted in the screening's audit record.
type JurisdictionRisk struct {
	// Jurisdiction is the canonical identifier: ISO 3166-1 alpha-2 for
	// countries ("CU", "IR", "KP"), ISO 3166-2 for the Ukrainian
	// regions ("UA-43", "UA-40", "UA-14", "UA-09").
	Jurisdiction string `json:"jurisdiction"`

	// Name is the human-readable jurisdiction name.
	Name string `json:"name"`

	// Program is the OFAC sanctions program establishing the embargo.
	Program string `json:"program"`

	// Authority is the regulatory citation (e.g. "31 CFR Part 560").
	Authority string `json:"authority"`

	// Level is always "comprehensive_embargo" today (see
	// JurisdictionLevelComprehensiveEmbargo). An enum rather than a bool so
	// lesser program levels can be added without a contract break.
	Level string `json:"level"`

	// Guidance is fixed EDD advisory text explaining what the signal means
	// and that it does not block the subject.
	Guidance string `json:"guidance"`
}

// JurisdictionLevelComprehensiveEmbargo is the only JurisdictionRisk.Level
// value emitted today: the jurisdiction is under a comprehensive OFAC
// embargo (country program or embargoed region).
const JurisdictionLevelComprehensiveEmbargo = "comprehensive_embargo"

// OwnershipMatch reports what Noble's ownership graph knows about the
// screened name under OFAC's 50 Percent Rule: an entity owned 50% or more
// in the aggregate, directly or indirectly, by one or more blocked persons
// is itself blocked — even though it appears on no sanctions list (OFAC
// FAQs 398/401; "Entities Owned by Blocked Persons", August 13, 2014).
//
// The determination is precomputed from the ownership graph (sanctions-list
// ownership assertions corroborated by GLEIF corporate-relationship data)
// and looked up by name at screening time — it adds no meaningful latency
// and never alters match scores or the summary verdict.
//
// Present on every live /v1/screen response that screened a name, on
// deployments with ownership screening enabled. Absent on identifier-only
// requests and on as_of (point-in-time) screenings — the ownership graph
// reflects current data only, and attaching today's ownership to a
// historical inquiry would misstate the record.
type OwnershipMatch struct {
	// Status is the ownership determination for the screened name:
	//
	//   "blocked" — sanctioned parties hold >= 50% in the aggregate, from
	//               edges with explicit percentages. The entity is a blocked
	//               person under the 50 Percent Rule; treat it as an SDN hit.
	//   "review"  — an ownership or control relationship to a sanctioned
	//               party exists, but no source discloses a percentage
	//               (e.g. a GLEIF consolidation parent, or an OFAC
	//               "Linked To" designation pointer). The relationship is
	//               real; the stake is unknown. A compliance officer must
	//               investigate. Not a blocked state.
	//   "clean"   — the entity is in the ownership graph and was traversed;
	//               no sanctioned ownership was found.
	//   "unknown" — the name is not in the ownership graph. Absence of a
	//               finding is not a finding: the graph covers entities
	//               named by sanctions lists and GLEIF-registered
	//               relationships, not the universe of companies.
	Status string `json:"status"`

	// EntityName is the name the screened input resolved to in the
	// ownership graph: the entity's primary name when an ownership
	// determination exists, otherwise the indexed name that matched (in
	// normalized, lowercase form). Compare it against the screened input —
	// name resolution is fuzzy, and this field shows exactly which entity
	// the determination is about. Empty when Status is "unknown".
	EntityName string `json:"entity_name,omitempty"`

	// NameSimilarity is the trigram similarity (0-100) between the
	// normalized screened name and the graph name it resolved to. 100 is an
	// exact normalized match. Omitted when Status is "unknown".
	NameSimilarity float64 `json:"name_similarity,omitempty"`

	// CumulativePercent is the aggregate percentage of the entity held by
	// sanctioned parties, summed across all ownership chains with explicit
	// percentages. Populated only when Status is "blocked" (>= 50 by
	// definition). Review-status relationships carry no percentage.
	CumulativePercent float64 `json:"cumulative_percent,omitempty"`

	// BlockedByLists names the sanctions lists whose designated entities
	// appear in Chains (e.g. ["ofac", "uk"]).
	BlockedByLists []string `json:"blocked_by_lists,omitempty"`

	// Chains are the discrete ownership paths from the screened entity up
	// to its sanctioned owners — the examiner-grade evidence behind Status.
	// Ordered by first-hop stake, descending.
	Chains []OwnershipPath `json:"chains,omitempty"`

	// AsOf is the date (YYYY-MM-DD) of the ownership computation this
	// determination came from.
	AsOf string `json:"as_of,omitempty"`

	// SourcePublishIDs pins the snapshot of every data source that
	// contributed to the ownership computation (source name -> snapshot
	// identifier), so the determination is reproducible for audit.
	SourcePublishIDs map[string]string `json:"source_publish_ids,omitempty"`
}

// OwnershipPath is one ownership chain from the screened entity up to a
// sanctioned owner. Hops[0] is the direct owner of the screened entity;
// the last hop is the sanctioned party the chain terminates at.
type OwnershipPath struct {
	// Percent is the stake the chain's first hop holds in the screened
	// entity — this chain's contribution to the >= 50% aggregate. Per OFAC
	// FAQ 401, indirect ownership propagates only through links that are
	// themselves >= 50%, so a chain's contribution is its first-hop stake,
	// never a product of percentages along the path. Zero on review-status
	// chains (percentage undisclosed).
	Percent float64 `json:"percent"`

	// Hops are the ownership steps, ordered from the screened entity's
	// direct owner upward.
	Hops []OwnershipHop `json:"hops"`
}

// OwnershipHop is one ownership step: an owner, and its stake in the
// entity one step below it in the chain.
type OwnershipHop struct {
	// EntityKey is the owner's canonical key in the ownership graph
	// (e.g. "sdn:36419", "lei:5493001KJTIIGC8Y1R12").
	EntityKey string `json:"entity_key"`

	// EntityName is the owner's primary name.
	EntityName string `json:"entity_name"`

	// PercentOwned is this owner's stake in the entity one step below it.
	// Omitted when the source discloses no percentage.
	PercentOwned float64 `json:"percent_owned,omitempty"`

	// IsSanctioned reports whether this owner is itself designated on a
	// sanctions list.
	IsSanctioned bool `json:"is_sanctioned"`

	// SanctionedList names the designating list(s) when IsSanctioned,
	// comma-separated (e.g. "ofac", "ofac,uk").
	SanctionedList string `json:"sanctioned_list,omitempty"`

	// Source identifies the data source that asserted this ownership step:
	// "gleif" (GLEIF corporate-relationship data), "ofac_linked_to" (an
	// OFAC "(Linked To:)" designation pointer), or "list_text:<list>" (an
	// ownership statement extracted from that list's published narrative
	// text, retained verbatim in Noble's audit store).
	Source string `json:"source"`

	// Confidence grades the source assertion: "high" (explicit percentage
	// from an authoritative source), "medium" (GLEIF consolidation
	// relationship), or "low" (designation linkage without an ownership
	// assertion).
	Confidence string `json:"confidence"`
}

// OwnershipMatch.Status values.
const (
	OwnershipStatusBlocked = "blocked"
	OwnershipStatusReview  = "review"
	OwnershipStatusClean   = "clean"
	OwnershipStatusUnknown = "unknown"
)

// SummaryVerdict is a deterministic classification of screening results,
// computed from match scores. The verdict is reproducible from the structured
// response alone — it is never decided by an LLM.
type SummaryVerdict string

// Verdict buckets driven by the top match score.
const (
	VerdictConfirmedMatch     SummaryVerdict = "confirmed_match"      // top_score >= 95
	VerdictStrongMatch        SummaryVerdict = "strong_match"         // top_score >= 80
	VerdictPossibleMatch      SummaryVerdict = "possible_match"       // top_score >= 65
	VerdictWeakMatch          SummaryVerdict = "weak_match"           // top_score >= 50
	VerdictNoSignificantMatch SummaryVerdict = "no_significant_match" // < 50 or no matches
)

// Prohibition type constants classify the regulatory action an OFAC hit
// carries, per OFAC's BLOCK vs. REJECT vs. restricted-dealings framework
// (Introduction to OFAC, June 2026, pp. 7-8). Surfaced on ScreenMatch.ProhibitionType.
const (
	ProhibitionBlocking       = "blocking"        // SDN and 50%-Rule entities: freeze property, file a blocking report
	ProhibitionCMICInvestment = "cmic_investment" // NS-CMIC (E.O. 13959): securities investment ban; NOT blocked, 50% Rule does not apply
	ProhibitionSSISectoral    = "ssi_sectoral"    // SSI (E.O. 13662 Directives): restricted new debt/equity/services; NOT blocked
	ProhibitionFSEReject      = "fse_reject"      // FSE (E.O. 13608): reject the transaction
	ProhibitionPLCReject      = "plc_reject"      // NS-PLC: reject the transaction
	ProhibitionCAPTA          = "capta"           // CAPTA: correspondent/payable-through-account restriction
)

// ScreenSummary is an executive synthesis of a screening result. Populated
// on every screening response — the deterministic path is pure-function and
// emits a verdict bucket, top match, distinct lists, strong-match count, and
// a one-line prose synthesis at no I/O cost. Callers that want LLM-rewritten
// prose pass DeepSummary=true (paid tier).
//
// Verdict is always deterministic — even in LLM mode, the bucket is computed
// from scores and the LLM only rewrites the prose. This keeps the audit trail
// reproducible from structured data alone.
type ScreenSummary struct {
	// Verdict is a coarse classification of the screening result, computed
	// from the top match score (see Verdict* constants).
	Verdict SummaryVerdict `json:"verdict"`

	// Text is a one-line human-readable synthesis. Up to ~280 characters.
	Text string `json:"text"`

	// TopMatch identifies the single highest-scoring match. Absent (omitted from
	// JSON) when no matches were returned. Lets callers act on the winning hit
	// without reparsing the full Matches array.
	TopMatch *SummaryTopMatch `json:"top_match,omitempty"`

	// StrongMatches counts matches with score >= 80.
	StrongMatches int `json:"strong_matches"`

	// DistinctLists names the unique source labels present in matches
	// (e.g., ["OFAC SDN", "UK", "France"]).
	DistinctLists []string `json:"distinct_lists"`

	// GeneratedBy identifies how Text was produced:
	// "deterministic" or "llm:<model-id>" (e.g., "llm:gemini-flash-latest";
	// the id is the exact serving model when the API reports one).
	GeneratedBy string `json:"generated_by"`

	// ModelVersion is the exact model identifier when GeneratedBy starts with
	// "llm:" (empty for deterministic).
	ModelVersion string `json:"model_version,omitempty"`

	// FallbackReason is populated only when the caller requested
	// deep_summary=true but the LLM path did not execute and the response
	// fell back to deterministic prose. Empty when deep_summary was not
	// requested or when the LLM successfully produced the text. See the
	// FallbackReason* constants for the documented values. Surfacing this
	// prevents silent paid-feature non-delivery.
	//
	// Invariant: on a deep_summary=true response, either GeneratedBy starts
	// with "llm:" or FallbackReason is non-empty. Callers can rely on this
	// to detect silent paid-feature degradation without parsing prose.
	FallbackReason string `json:"fallback_reason,omitempty"`
}

// Fallback reason codes for ScreenSummary.FallbackReason and
// ScreenResponse.DeepScreenFallbackReason.
const (
	// FallbackReasonLLMUnavailable indicates the server is not configured to
	// call an LLM (e.g., GEMINI_API_KEY is unset). Operator action required.
	FallbackReasonLLMUnavailable = "llm_unavailable"

	// FallbackReasonLLMFailure indicates the LLM call returned an error
	// (network failure, timeout, rate limit, invalid response). The
	// deterministic summary is returned instead.
	FallbackReasonLLMFailure = "llm_failure"

	// FallbackReasonVariationScreeningFailure (deep_screen only) indicates
	// the LLM produced name variations but one or more of them could not be
	// screened against the sanctions lists. The response carries every match
	// that was found, but the enhanced recall is incomplete —
	// ScreenResponse.DeepScreenCoverage reports exactly how many variations
	// were screened out of how many were generated. Retry the request to
	// re-attempt full coverage.
	FallbackReasonVariationScreeningFailure = "variation_screening_failure"
)

// SummaryTopMatch identifies the single highest-scoring match within a
// screening result and carries OFAC FAQ 1591's 5-step validation set for that
// entity: name, date of birth, place of birth, nationality, ID, and address.
// Exposing these on the summary lets AI agents and compliance officers
// confirm or dismiss the top hit without a second round-trip into Matches.
//
// All fields refer to the same entity — drawn from whichever ScreenMatch in
// the response carries the maximum Score. SummaryTopMatch is a pointer field
// on ScreenSummary so it is omitted from JSON entirely when no matches were
// returned.
type SummaryTopMatch struct {
	// Score is the highest combined match score across all matches (0-100).
	Score float64 `json:"score"`

	// UID is the source-list record identifier of the top match
	// (e.g., the OFAC UID). This is the list pointer, not a government ID
	// — see IDs for passports, tax IDs, and cedulas.
	UID string `json:"uid,omitempty"`

	// Name is the rendered display name of the top match
	// ("Last, First" for individuals, otherwise the last/full-name field).
	Name string `json:"name,omitempty"`

	// Source is the sanctions list that produced the top match
	// (e.g., "OFAC SDN", "UK", "France").
	Source string `json:"source,omitempty"`

	// DateOfBirth from the sanctions list, in the publisher's original format
	// (commonly YYYY-MM-DD or a free-form string). Empty when not provided
	// by the source list.
	DateOfBirth string `json:"date_of_birth,omitempty"`

	// PlaceOfBirth from the sanctions list (free-form city/region/country).
	PlaceOfBirth string `json:"place_of_birth,omitempty"`

	// Nationalities lists every nationality the source has on file for the
	// entity. OFAC and other publishers sometimes record multiple — all are
	// retained verbatim.
	Nationalities []string `json:"nationalities,omitempty"`

	// IDs are government identifiers (passport, tax ID, national ID, cedula)
	// for the entity. Used in OFAC 5-step validation step 3 (ID match).
	IDs []MatchID `json:"ids,omitempty"`

	// Addresses associated with the entity on the sanctions list. Used in
	// OFAC 5-step validation step 5 (address match).
	Addresses []MatchAddress `json:"addresses,omitempty"`
}

// ScreenMatch represents a single matched sanctions entry.
// Includes all secondary identifiers from the source list so compliance officers
// can perform OFAC's 5-step validation (FAQ 1591) without a separate lookup.
type ScreenMatch struct {
	// UID is the unique identifier from the source list (e.g., OFAC UID).
	UID string `json:"uid"`

	// First name of the sanctioned entity (if individual)
	FirstName *string `json:"first_name,omitempty"`

	// Last name or full name of the sanctioned entity
	LastName *string `json:"last_name,omitempty"`

	// Type of entry: Individual, Entity, Vessel, Aircraft
	SdnType *string `json:"sdn_type,omitempty"`

	// Combined match score (0-100), higher is stronger match.
	// When secondary attributes (DOB, country) are provided in the request,
	// this score is adjusted downward for mismatches.
	Score float64 `json:"score"`

	// Additional remarks from the source list
	Remarks *string `json:"remarks,omitempty"`

	// Source of the match (e.g., "OFAC SDN", "OFAC CONS", "UK", "EU")
	Source string `json:"source,omitempty"`

	// Sanctions programs this entity is listed under (e.g., "SDGT", "IRAN", "UKRAINE-EO13662").
	// Essential for determining which regulations apply and which general licenses may be relevant.
	Programs []string `json:"programs,omitempty"`

	// ProhibitionType is the regulatory action this OFAC hit carries — which is
	// NOT the same for every designation, and drives whether a transaction is
	// blocked, rejected, or merely restricted. A full SDN listing (and a
	// 50%-Rule entity) is "blocking" — freeze the property and file a blocking
	// report. A CMIC listing (E.O. 13959) is a securities "cmic_investment" ban
	// with no blocking. An SSI listing (E.O. 13662) is "ssi_sectoral" restricted
	// dealings, not blocked. FSE and NS-PLC are reject actions; CAPTA restricts
	// correspondent/payable-through accounts. Derived from Programs. Populated
	// for OFAC matches only — the taxonomy is OFAC-specific; empty for other
	// lists, which follow their own action frameworks. One of the Prohibition*
	// constants.
	ProhibitionType string `json:"prohibition_type,omitempty"`

	// WeakAliasOnly is true when every pathway that produced this match fired
	// via a weak OFAC AKA — no primary name, strong alias, variation, ID, or
	// other list corroborates it. OFAC does not expect a weak-AKA-only hit to
	// be treated like a strong-name match (FAQ 122-124), so this is the
	// primary false-positive control: callers may deprioritize or auto-dismiss
	// these. Per-pathway detail is on MatchSource.AliasQuality. Absent (false)
	// when any corroborating pathway is present.
	WeakAliasOnly bool `json:"weak_alias_only,omitempty"`

	// Per-source scoring breakdown showing how and why this entity matched
	MatchSources []MatchSource `json:"match_sources"`

	// DismissalReasons explains why the score was adjusted downward.
	// Only present when secondary attributes (DOB, country, entity_type) were provided
	// and a mismatch was detected.
	//
	// Deprecated: free-text reasons cannot be filtered, aggregated, or
	// audit-mapped. Use DismissalSignals for the structured form. Both
	// fields are populated in parallel during a 90-day deprecation window
	// and are guaranteed to have equal length and ordered correspondence.
	DismissalReasons []string `json:"dismissal_reasons,omitempty"`

	// DismissalSignals is the structured form of DismissalReasons: stable
	// reason codes, severity tiers, the conflicting request/target values,
	// and the exact point reduction each signal applied to the score.
	//
	// Always emitted — empty array means "no penalties applied," never
	// "we forgot." See the Dismissal* constants for the stable code set.
	// Each signal's position in this array corresponds 1:1 with the
	// position of its free-text form in DismissalReasons.
	DismissalSignals []DismissalSignal `json:"dismissal_signals"`

	// MatchedVia names which submitted identifier(s) triggered this match,
	// when the hit came from ID-based screening (ScreenRequest.IDNumber or
	// any entry in ScreenRequest.IDs). Empty when the match came only from
	// name-based screening. A single match may carry multiple entries when
	// several submitted IDs all resolve to the same sanctioned entity.
	MatchedVia []IDInput `json:"matched_via,omitempty"`

	// Secondary identifiers from the sanctions list for OFAC 5-step validation.
	// These enable compliance officers to compare against transaction data.
	Addresses     []MatchAddress `json:"addresses,omitempty"`
	IDs           []MatchID      `json:"ids,omitempty"`
	DateOfBirth   string         `json:"date_of_birth,omitempty"`
	PlaceOfBirth  string         `json:"place_of_birth,omitempty"`
	Nationalities []string       `json:"nationalities,omitempty"`
	Citizenships  []string       `json:"citizenships,omitempty"`
}

// MatchAddress is an address associated with a sanctioned entity.
type MatchAddress struct {
	Address1        string `json:"address1,omitempty"`
	Address2        string `json:"address2,omitempty"`
	City            string `json:"city,omitempty"`
	StateOrProvince string `json:"state_or_province,omitempty"`
	PostalCode      string `json:"postal_code,omitempty"`
	Country         string `json:"country,omitempty"`
}

// MatchID is a government identifier (passport, tax ID, cedula) for a sanctioned entity.
type MatchID struct {
	IDType  string `json:"id_type"`
	Number  string `json:"number"`
	Country string `json:"country,omitempty"`
}

// MatchSource provides scoring details for a single match pathway.
// An entity may match via multiple sources (e.g., both direct SDN and AI variation).
type MatchSource struct {
	// How the match was found: "ofac_sdn", "ofac_aka", "ofac_ai_variation",
	// "ofac_id", "uk_designation", "uk_alias", "uk_id", "deep_screen",
	// or "internal_watchlist"
	Source string `json:"source"`

	// Raw trigram similarity score (0-100) for this source
	TrigramScore float64 `json:"trigram_score"`

	// Whether the last name matched phonetically via Soundex
	SoundexMatch bool `json:"soundex_match"`

	// The AI-generated variation that matched (only for source="ofac_ai_variation" or "deep_screen")
	Variation string `json:"variation,omitempty"`

	// Why Gemini generated this variation (only for source="ofac_ai_variation" or "deep_screen")
	Reason string `json:"reason,omitempty"`

	// MatchedText is the source-list string that produced this pathway's
	// trigram score — the actual primary name, alias, or variation text
	// the comparison fired against. Lets consumers distinguish "matched
	// primary name 'Vladimir Putin'" from "matched 4-character alias
	// 'JOHN'" and is the foundation for length- and quality-aware
	// reweighting (and the calibration corpus those depend on).
	MatchedText string `json:"matched_text,omitempty"`

	// MatchedLength is the rune count of MatchedText. Pre-computed so
	// aggregations like "fraction of dismissed alerts with matched_length
	// <= 5" can run server-side without scanning every match.
	MatchedLength int `json:"matched_length,omitempty"`

	// AliasQuality is OFAC's alias-strength classification — "weak" or
	// "strong" — when this pathway fired via an OFAC AKA (source "ofac_aka" or
	// "ofac_cons_aka"); empty for primary-name, variation, ID, and non-OFAC
	// pathways. OFAC flags weak AKAs because they are a dominant false-positive
	// source and does not expect a weak-AKA-only hit to be treated like a
	// strong-name match (FAQ 122-124). Use it to deprioritize or suppress hits
	// driven solely by a weak alias — see ScreenMatch.WeakAliasOnly.
	AliasQuality string `json:"alias_quality,omitempty"`
}

// =============================================================================
// Dismissal Signals
// =============================================================================
//
// Structured replacement for the legacy ScreenMatch.DismissalReasons []string
// field. Every penalty applied during attribute comparison emits a
// DismissalSignal carrying the canonical reason code, severity, conflicting
// request/target values, and the score impact.
//
// Reason codes are stable identifiers — consumers filter, aggregate, and
// audit-map on them. They are grounded in OFAC FAQ 5 ("How do I determine
// if I have a valid OFAC match?") where applicable: Step 2 (entity-type
// sanity), Step 3 (single-token name match), and Step 4 (full-entry
// comparison covering DOB, country, and the broader identity set).

// DismissalSignal is a structured reason a match's score was reduced.
type DismissalSignal struct {
	// Field that drove the signal: "date_of_birth", "country",
	// "entity_type", or "name".
	Field string `json:"field"`

	// ReasonCode is one of the Dismissal* constants below. Stable across
	// releases — clients depend on these strings.
	ReasonCode string `json:"reason_code"`

	// Severity is the strength tier of this signal. High signals are
	// safe-to-auto-dismiss; low signals are advisory.
	Severity DismissalSeverity `json:"severity"`

	// RequestValue is the value the request supplied, rendered as text.
	RequestValue string `json:"request_value,omitempty"`

	// TargetValue is the value the source list carries, rendered as text.
	TargetValue string `json:"target_value,omitempty"`

	// ImpactOnScore is the point reduction this signal applied to the
	// match score (negative, on the same 0-100 scale as Score).
	ImpactOnScore float64 `json:"impact_on_score"`
}

// DismissalSeverity is the strength tier of a DismissalSignal. Derived
// from penalty depth — locked in code, not data, so the mapping is
// reproducible from the response alone.
type DismissalSeverity string

const (
	SeverityLow    DismissalSeverity = "low"
	SeverityMedium DismissalSeverity = "medium"
	SeverityHigh   DismissalSeverity = "high"
)

// Dismissal reason codes. Stable identifiers — clients filter on these.
//
//   - DismissalEntityTypeMismatch and DismissalPartialNameMatchSingleToken
//     are OFAC FAQ 5 Steps 2 and 3 respectively (not-a-valid-match
//     conditions).
//   - The DOB and country codes are OFAC FAQ 5 Step 4 (full-entry
//     comparison). The "_far" suffix splits today's overloaded
//     dob_year_mismatch and dob_mismatch into distinct codes per
//     penalty depth.
const (
	DismissalEntityTypeMismatch          = "entity_type_mismatch"
	DismissalPartialNameMatchSingleToken = "partial_name_match_single_token"
	DismissalDOBYearNearMiss             = "dob_year_near_miss"
	DismissalDOBYearMismatch             = "dob_year_mismatch"
	DismissalDOBYearMismatchFar          = "dob_year_mismatch_far"
	DismissalDOBNearMiss                 = "dob_near_miss"
	DismissalDOBMismatch                 = "dob_mismatch"
	DismissalDOBMismatchFar              = "dob_mismatch_far"
	DismissalCountryMismatch             = "country_mismatch"
)

// =============================================================================
// Sanctions List Selection
// =============================================================================

// List constants for selective screening.
const (
	ListOFAC        = "ofac"
	ListUK          = "uk"
	ListEU          = "eu"
	ListFrance      = "france"
	ListBelgium     = "belgium"
	ListNetherlands = "netherlands"
	ListUN          = "un"
	ListCanada      = "canada"
	ListAustralia   = "australia"
)

// AllLists is the complete set of supported sanctions lists.
var AllLists = []string{ListOFAC, ListUK, ListEU, ListFrance, ListBelgium, ListNetherlands, ListUN, ListCanada, ListAustralia}

// validLists is the lookup set for O(1) validation.
var validLists = map[string]bool{
	ListOFAC: true, ListUK: true, ListEU: true,
	ListFrance: true, ListBelgium: true, ListNetherlands: true,
	ListUN: true, ListCanada: true, ListAustralia: true,
}

// ValidateListNames returns an error message for the first invalid list name, or "".
func ValidateListNames(lists []string) string {
	for _, l := range lists {
		if !validLists[l] {
			return fmt.Sprintf("unknown list %q; valid values: %s", l, strings.Join(AllLists, ", "))
		}
	}
	return ""
}

// DeduplicateLists removes duplicate list names, preserving order.
func DeduplicateLists(lists []string) []string {
	seen := make(map[string]bool, len(lists))
	out := make([]string, 0, len(lists))
	for _, l := range lists {
		if !seen[l] {
			seen[l] = true
			out = append(out, l)
		}
	}
	return out
}

// =============================================================================
// Common Types
// =============================================================================

// Error type categories for machine-readable error handling.
const (
	ErrTypeInvalidRequest  = "invalid_request_error"
	ErrTypeAuthentication  = "authentication_error"
	ErrTypeForbidden       = "forbidden_error"
	ErrTypeRateLimit       = "rate_limit_error"
	ErrTypeAPI             = "api_error"
	ErrTypeNotFound        = "not_found_error"
	ErrTypeConflict        = "conflict_error"
	ErrTypeComplianceRule  = "compliance_rule_error"
	ErrTypePaymentRequired = "payment_required"
)

// Error code constants for machine-readable error handling.
const (
	CodeInvalidBody              = "invalid_request_body"
	CodeNameRequired             = "name_required"
	CodeQueryRequired            = "query_required"
	CodeNameTooLong              = "name_too_long"
	CodeMissingParameter         = "missing_parameter"
	CodeInvalidParameter         = "invalid_parameter"
	CodeKeyRequired              = "api_key_required"
	CodeKeyInvalid               = "api_key_invalid"
	CodeTenantNotProvisioned     = "tenant_not_provisioned"
	CodeTierInsufficient         = "tier_insufficient"
	CodeRateLimitExceeded        = "rate_limit_exceeded"
	CodeBatchTooLarge            = "batch_too_large"
	CodeTooManyIDs               = "too_many_ids"
	CodeNotFound                 = "not_found"
	CodeInvalidTransition        = "invalid_status_transition"
	CodeInvalidReason            = "invalid_reason_code"
	CodeInvalidDisposition       = "invalid_disposition"
	CodeDispositionRequired      = "disposition_required"
	CodeGLCitationRequired       = "gl_citation_required"
	CodeFourEyesViolation        = "four_eyes_violation"
	CodeConcurrentModification   = "concurrent_modification"
	CodeInvalidAssignee          = "invalid_assignee"
	CodeNoteEmpty                = "note_empty"
	CodeNoteTooLong              = "note_too_long"
	CodeInternalError            = "internal_error"
	CodeKeyRevoked               = "encryption_key_revoked"
	CodeConfirmationRequired     = "confirmation_required"
	CodeMethodNotAllowed         = "method_not_allowed"
	CodeWebhookURLInvalid        = "webhook_url_invalid"
	CodeWebhookSecretTooShort    = "webhook_secret_too_short"
	CodeWebhookNotFound          = "webhook_not_found"
	CodeWebhookLimitExceeded     = "webhook_limit_exceeded"
	CodeWebhookSignatureInvalid  = "webhook_signature_invalid"
	CodeTrialExpired             = "trial_expired"
	CodeSignupClosed             = "signup_closed"
	CodeInviteRequired           = "invite_code_required"
	CodeInviteInvalid            = "invite_code_invalid"
	CodeInviteExpired            = "invite_code_expired"
	CodeInviteExhausted          = "invite_code_exhausted"
	CodeInviteRevoked            = "invite_code_revoked"
	CodeIdempotencyMismatch      = "idempotency_key_mismatch"
	CodeIdempotencyInProgress    = "idempotency_in_progress"
	CodeIdempotencyKeyTooLong    = "idempotency_key_too_long"
	CodeScopeRequired            = "scope_required"
	CodeScopeNoChange            = "scope_no_change"
	CodeReplayUnsupportedChannel = "replay_unsupported_channel"
	CodeInvalidVersion           = "invalid_version"
)

// Error is the standard error response following Stripe's error model.
type Error struct {
	// Error category: invalid_request_error, authentication_error, rate_limit_error, api_error
	Type string `json:"type"`

	// Machine-readable error code for programmatic handling
	Code string `json:"code"`

	// Human-readable error message
	Message string `json:"message"`

	// Which request parameter caused the error (if applicable)
	Param string `json:"param,omitempty"`

	// Server-generated request ID for debugging and audit correlation
	RequestID string `json:"request_id"`
}

// ResponseMeta contains metadata included in every API response.
type ResponseMeta struct {
	// Legal disclaimer bounding Noble Sight's liability
	Disclaimer string `json:"disclaimer"`
}

// Disclaimer is the standard legal disclaimer included in all screening API responses.
// This text is a material part of Noble Sight's liability boundary and must not be
// modified without legal review.
const Disclaimer = "Noble Sight provides sanctions screening results for informational purposes only. " +
	"This service is not a substitute for a comprehensive sanctions compliance program. " +
	"Noble Sight does not provide legal, regulatory, or compliance advice. " +
	"Screening results reflect data available at the time of the request and may not capture all " +
	"sanctions designations, aliases, or name variations. " +
	"Final screening decisions, risk assessments, and compliance obligations remain the sole " +
	"responsibility of the subscribing institution. " +
	"Use of this service does not satisfy or replace any obligation under OFAC regulations, " +
	"the Bank Secrecy Act, or any other applicable law."

// =============================================================================
// Pagination — the uniform contract for every collection endpoint
// =============================================================================
//
// Every list endpoint (alerts, portfolio, watchlist, audit export, case
// activity) returns the same envelope and paginates the same way. This section
// is the contract; handlers must not deviate.
//
// # Envelope
//
// A list response is always:
//
//	{ "data": [ ... ], "has_more": true, "meta": { "disclaimer": "..." } }
//
// `data` is the page of items (the array key is ALWAYS "data" — never "alerts",
// "entities", "records", etc.). `has_more` is true when at least one more item
// exists past this page. There is no other top-level shape.
//
// # Cursor pagination only — never offset
//
// Pagination is keyset (cursor) based:
//
//	GET /v1/portfolio?after=<id>&limit=<n>
//
//   - `after` — exclusive cursor: return items with id > after. Omit (or 0) for
//     the first page. The cursor is the `id` of the last item in the previous
//     page's `data`. Results are ordered by `id` ascending (chronological and
//     stable, since id is a monotonic BIGSERIAL). A descending/`before` cursor
//     may be ADDED later without breaking this contract.
//   - `limit` — page size; defaults to DefaultPageLimit, hard-capped at
//     MaxPageLimit. Values above the cap are clamped, not rejected.
//
// Offset/page pagination (`?offset=`, `?page=`) is PROHIBITED. It is O(n) at
// depth — the database scans and discards `offset` rows on every request — and
// it returns duplicate or skipped rows when items are inserted concurrently,
// which is unacceptable for an append-only audit system. The scale that forces
// this: a portfolio-monitoring customer (e.g. a top-tier bank) may enroll tens
// of millions of entities; offset page 500,000 would scan ~50M rows per call,
// while a keyset cursor stays O(log n + limit) at any depth.
//
// # No `total` in list responses — counts live in a /stats companion
//
// List responses do NOT carry a row count. COUNT(*) over a large, filtered, or
// date-ranged set adds latency and lock contention to every page fetch and is
// racy under concurrent writes. Counts are instead a first-class, cheap read on
// a dedicated companion endpoint, uniformly: a collection at `/v1/<name>`
// exposes its counts at `/v1/<name>/stats`. Every stats response carries at
// least a `total`; most add a small, cheap breakdown (by_status, by_score_band,
// by_action, …). A stats endpoint honors the SAME filters as its list sibling,
// so the count matches the page the caller is browsing.
//
//	/v1/alerts          → /v1/alerts/stats           (AlertStatsResponse)
//	/v1/portfolio       → /v1/portfolio/stats        (PortfolioStatsResponse)
//	/v1/webhooks        → /v1/webhooks/stats          (WebhookStatsResponse)
//	/v1/alerts/activity → /v1/alerts/activity/stats   (ActivityStatsResponse)
//
// # Full extraction is not pagination
//
// Paging through millions of rows via GET is for browsing/triage, not bulk
// extraction. A customer who needs their entire portfolio or audit history
// pulls it via the async export/batch job, which is built for volume.
//
// # Per-endpoint application
//
//	/v1/alerts          cursor + filters (already the reference implementation)
//	/v1/portfolio       cursor (requires a (tenant_id, id) index — see migrations)
//	/v1/watchlist       cursor
//	/v1/export/bulk     cursor + hard limit; large pulls use the export job
//	/v1/alerts/activity cursor + hard limit
//	/v1/webhooks        NO pagination — capped at a handful per tenant; returns
//	                    the {data} envelope with every webhook, no cursor params.

// List is the uniform envelope returned by every collection endpoint. The
// generic parameter is the item type (e.g. List[AlertResponse]). The "data"
// key and "has_more" flag are invariant across all list endpoints; meta is
// injected by the response writer.
type List[T any] struct {
	// Data is the page of items, ordered by id ascending.
	Data []T `json:"data"`
	// HasMore reports whether at least one item exists past this page. When
	// true, request the next page with ?after=<id of the last item in Data>.
	HasMore bool `json:"has_more"`
}

// Pagination bounds for list endpoints.
const (
	DefaultPageLimit = 50  // page size when ?limit is omitted
	MaxPageLimit     = 100 // hard cap; larger ?limit values are clamped to this
)

// PageParams is the parsed cursor-pagination query shared by list handlers.
// Produced by parsePageParams; never construct it from offset/page inputs.
type PageParams struct {
	// After is the exclusive id cursor: return items with id > After.
	// Zero means start from the beginning.
	After int64
	// Limit is the clamped page size, in [1, MaxPageLimit].
	Limit int
}

// =============================================================================
// Changelog
// =============================================================================
//
// GET /v1/changelog returns the API's release notes as structured JSON so that
// customers — banks, AI compliance agents, MCP clients — can surface "what's
// new" inside their own dashboards without scraping HTML. The endpoint is
// public (no API key required) so prospects and discovery tools can read it.
//
// The source of truth is a checked-in Go slice (api/v1/changelog.go); new
// entries ship with the binary version and are reviewed in PRs.

// Changelog categories. Follows the Keep-a-Changelog convention
// (https://keepachangelog.com).
const (
	ChangeAdded      = "added"
	ChangeChanged    = "changed"
	ChangeDeprecated = "deprecated"
	ChangeRemoved    = "removed"
	ChangeFixed      = "fixed"
	ChangeSecurity   = "security"
)

// ChangelogEntry is one published API change.
type ChangelogEntry struct {
	// Release date in YYYY-MM-DD (UTC).
	Date string `json:"date"`

	// One of ChangeAdded, ChangeChanged, ChangeDeprecated, ChangeRemoved,
	// ChangeFixed, ChangeSecurity.
	Category string `json:"category"`

	// Single-line headline. Required.
	Summary string `json:"summary"`

	// Optional longer body. Plain text; no HTML.
	Details string `json:"details,omitempty"`

	// Optional endpoint the change scopes to (e.g. "/v1/screen").
	Endpoint string `json:"endpoint,omitempty"`

	// Breaking marks this entry as an API version boundary: a change that
	// altered or removed existing response shape, minting a new dated
	// Noble-Version equal to Date. The set of supported API versions is derived
	// from exactly the Breaking entries (see version.go), so the changelog is
	// the single source of truth and the two cannot drift. Additive entries
	// leave this false and never mint a version.
	Breaking bool `json:"breaking,omitempty"`

	// Verification is a customer-runnable proof the change behaves as
	// described — Steps a caller executes against the live API with their own
	// key, an Expect assertion, and how to replay it for audit. Required on
	// every fix/feature/breaking entry dated on or after the verification
	// cutover (see changelog.go): a compliance vendor's changes must be
	// independently provable by the customer, not asserted. Optional (nil) on
	// historical entries that predate the policy.
	Verification *ChangelogVerification `json:"verification,omitempty"`
}

// ChangelogVerification is a self-contained, customer-runnable check that a
// changelog entry's change works as described. It carries no privileged steps:
// every command runs against the public API with the caller's own key.
type ChangelogVerification struct {
	// Steps are exact, copy-pasteable requests that exercise the change.
	Steps []string `json:"steps"`

	// Expect is the precise pass assertion (e.g. a field == value condition).
	Expect string `json:"expect"`

	// Audit is how to independently replay or prove the result after the fact
	// (trace id, export, list_versions). Optional.
	Audit string `json:"audit,omitempty"`

	// Regression links the test(s) that lock the change. Optional.
	Regression string `json:"regression,omitempty"`
}

// ChangelogResponse is returned by GET /v1/changelog.
type ChangelogResponse struct {
	// Entries are ordered newest first.
	Entries []ChangelogEntry `json:"entries"`
	Meta    ResponseMeta     `json:"meta"`
}

// =============================================================================
// Sanctions list versions
// =============================================================================
//
// GET /v1/lists returns the active publisher snapshot for every sanctions list
// Noble screens against. Public — no API key required — so customers and AI
// compliance agents can answer "are you screening against the latest OFAC?"
// without spending screening quota. The same per-list version info is also
// embedded in every /v1/screen response's list_versions field; this endpoint
// is the standalone fetch path used by status dashboards and the
// noble://sanctions-lists MCP resource.
//
// One entry per list. Lists that have never been imported into this Noble
// deployment are omitted (rather than emitting a sentinel row) so consumers
// don't need to special-case missing data.

// SanctionsListVersion describes the active publisher snapshot for a single
// sanctions list.
type SanctionsListVersion struct {
	// List is the canonical short identifier matching the values accepted in
	// ScreenRequest.Lists: "ofac", "uk", "eu", "france", "belgium",
	// "netherlands", "un", "canada", "australia".
	List string `json:"list"`

	// DisplayName is the human-readable name surfaced in dashboards
	// (e.g., "OFAC SDN", "UK OFSI Consolidated", "EU Financial Sanctions").
	DisplayName string `json:"display_name"`

	// PublishID is the publish_info row identifier — the audit pointer back
	// to the imported source file.
	PublishID int `json:"publish_id"`

	// PublishDate is the date the publisher released this version (YYYY-MM-DD),
	// taken verbatim from the source list.
	PublishDate string `json:"publish_date"`

	// RecordCount is the number of entries in this version.
	RecordCount int `json:"record_count"`

	// ImportedAt is when Noble ingested this version (RFC 3339, UTC).
	// Compare against PublishDate to see how stale the imported snapshot is.
	ImportedAt string `json:"imported_at"`

	// LastChecked is when Noble last polled the upstream publisher for this
	// list (RFC 3339, UTC) — the ingestion heartbeat. Unlike ImportedAt, it
	// advances on every poll even when the source is unchanged, so a recent
	// LastChecked is the proof that ingestion is live and you are on the
	// current published version. A LastChecked far in the past means the
	// watcher is not running, regardless of how healthy ImportedAt looks.
	// For OFAC (which polls the SDN and Consolidated files separately) this
	// is the oldest of the two checks, so a single dead watcher surfaces here.
	LastChecked string `json:"last_checked"`

	// LastImport is when the upstream content last actually changed and was
	// loaded (RFC 3339, UTC). Empty if the content has not changed since this
	// deployment began watching. For OFAC it is the most recent change across
	// both source files.
	LastImport string `json:"last_import"`

	// Status is the ingestion-freshness verdict for this list:
	//   "ok"    — LastChecked is within the freshness window (24h); the
	//             watcher is polling and this snapshot is trustworthy.
	//   "stale" — LastChecked is older than 24h or unknown; treat ingestion
	//             as potentially broken and investigate before relying on
	//             screening results for this list.
	// The verdict is intentionally fail-closed: when in doubt it reports
	// "stale" rather than implying freshness it cannot prove.
	Status string `json:"status"`
}

// Sanctions list ingestion-freshness verdicts (SanctionsListVersion.Status).
const (
	// ListStatusOK means the list was polled within the freshness window.
	ListStatusOK = "ok"
	// ListStatusStale means the list has not been polled within the freshness
	// window (or the last-poll time is unknown). Fail-closed default.
	ListStatusStale = "stale"
)

// ListFreshThreshold is the maximum age of SanctionsListVersion.LastChecked
// before a list's Status flips from "ok" to "stale". Noble commits to polling
// every list at least daily, so a gap beyond this points to a stalled watcher.
const ListFreshThreshold = 24 * time.Hour

// ListsResponse is returned by GET /v1/lists.
type ListsResponse struct {
	// Lists ordered by canonical short identifier (alphabetical).
	Lists []SanctionsListVersion `json:"lists"`
	Meta  ResponseMeta           `json:"meta"`
}

// =============================================================================
// Idempotency
// =============================================================================
//
// All state-mutating POST endpoints accept an optional Idempotency-Key request
// header. Noble caches the response for 24 hours under (scope, method, path,
// key); a retry with the same body returns the cached response and produces
// zero new side effects (no second screening_result row, no duplicate webhook
// delivery, no second alert activity row).
//
//   Idempotency-Key: <string>   (1-255 chars; UUID v4 recommended)
//
// Scope:
//   - Authenticated endpoints: the key is namespaced by tenant_id.
//   - POST /v1/keys (unauthenticated signup): the key is namespaced by the
//     client's source IP, so a customer whose first signup attempt's response
//     was lost mid-flight can retry and receive the original plaintext key.
//
// Behavior on a second request with the same (scope, method, path, key):
//
//   Same request body hash, first request completed
//     → Replay cached status + body + headers (X-Request-Id, X-RateLimit-*,
//       Content-Type). Replays do not consume rate-limit quota.
//
//   Same request body hash, first request still in flight (< 60s)
//     → 409 with code=idempotency_in_progress and Retry-After: 1.
//
//   Same request body hash, first request abandoned (>= 60s, no completion)
//     → Treat as new — claim the slot and re-process.
//
//   Different request body hash
//     → 400 with code=idempotency_key_mismatch. To retry with a different
//       body, generate a fresh Idempotency-Key.
//
//   Key longer than 255 characters
//     → 400 with code=idempotency_key_too_long.
//
// Caching policy:
//   - 2xx and 4xx responses ARE cached (4xx is deterministic from the request).
//   - 5xx responses are NOT cached (transient errors should be retryable).
//   - Replays do not re-trigger webhooks, do not insert new audit rows, and
//     do not consume rate-limit quota.
//
// Endpoints that honor Idempotency-Key:
//   POST /v1/screen, /v1/batch, /v1/portfolio, /v1/watchlist, /v1/webhooks,
//   /v1/alerts/{id}/resolve, /v1/alerts/{id}/assign, /v1/alerts/{id}/review,
//   /v1/alerts/{id}/escalate, /v1/alerts/{id}/notes,
//   /v1/alerts/bulk/resolve, /v1/alerts/bulk/assign,
//   /v1/account/checkout, /v1/keys.
//
// Endpoints that ignore Idempotency-Key (header is silently dropped):
//   GET and DELETE methods (idempotent by HTTP semantics), and /v1/admin/*
//   (operator-driven, Phase 1 only — revisit when IAP gating lands).

// MaxIdempotencyKeyLength caps the Idempotency-Key header at the Stripe-
// compatible 255-character limit. Longer keys return 400 idempotency_key_too_long.
const MaxIdempotencyKeyLength = 255

// IdempotencyRetentionHours is how long cached responses live before the
// hourly cleanup goroutine deletes them.
const IdempotencyRetentionHours = 24

// IdempotencyInProgressTimeoutSeconds is how long an in-progress row is
// considered live. After this window, the row is treated as abandoned and a
// retry may claim its slot. Matched to the server's WriteTimeout (60s) — any
// handler still in_progress past that point has already been killed by the
// timeout, so its row is safely reclaimable.
const IdempotencyInProgressTimeoutSeconds = 60

// =============================================================================
// POST /v1/batch - Submit a batch screening job
// =============================================================================

// BatchRequest is the input for batch screening.
type BatchRequest struct {
	// Names to screen (required, max 10,000 per batch)
	Names []BatchNameInput `json:"names"`

	// Lists restricts screening to the specified sanctions lists (batch-level).
	// Same values as ScreenRequest.Lists. Applied to all names in the batch.
	Lists []string `json:"lists,omitempty"`
}

// BatchNameInput is a single name entry in a batch request.
type BatchNameInput struct {
	// Full name to screen (required)
	FullName string `json:"full_name"`

	// Date of birth in YYYY-MM-DD format (optional)
	DateOfBirth string `json:"date_of_birth,omitempty"`

	// Country code ISO 3166-1 alpha-2 (optional)
	Country string `json:"country,omitempty"`

	// EntityType of the subject: "individual", "entity", "vessel", "aircraft"
	// (optional). Applied identically to ScreenRequest.EntityType — a genuine
	// type mismatch against the matched record reduces the score (OFAC FAQ 5
	// Step 2). Omitted means no entity-type penalty, matching /v1/screen.
	EntityType string `json:"entity_type,omitempty"`

	// IDNumber is a passport, tax ID, or other government identifier (optional).
	// When provided, actively matched against OFAC and UK sanctions ID tables.
	IDNumber string `json:"id_number,omitempty"`

	// IDType filters ID matching by type (e.g., "Passport") (optional).
	IDType string `json:"id_type,omitempty"`
}

// BatchResponse is the response after creating a batch job.
type BatchResponse struct {
	// Batch job ID for status polling
	ID int64 `json:"id"`

	// Current status: pending, processing, completed, failed.
	// A job that encounters an unrecoverable error is marked "failed" with an error_message.
	Status string `json:"status"`

	// Total names submitted
	TotalNames int `json:"total_names"`

	// Names processed so far
	ProcessedNames int `json:"processed_names"`

	// Names that had at least one match
	MatchCount int `json:"match_count"`

	// Detailed results per name (populated as items are screened; complete when status=completed)
	Items []BatchItemResponse `json:"items,omitempty"`
}

// BatchItemResponse is a single name result within a batch.
type BatchItemResponse struct {
	// Name that was screened
	FullName string `json:"full_name"`

	// Status: pending, screened, error
	Status string `json:"status"`

	// Number of sanctions matches found
	MatchCount int `json:"match_count"`

	// Highest match score (0-100)
	HighestScore float64 `json:"highest_score"`

	// Alert ID created for this name, if the highest score exceeded the tenant's
	// alert threshold. Zero/omitted means no alert was generated.
	AlertID int64 `json:"alert_id,omitempty"`

	// JurisdictionRisk is present when this item's country field places the
	// subject under a comprehensive OFAC embargo — identical detection to
	// ScreenResponse.JurisdictionRisk, so batch and single-screen can never
	// disagree about the same input.
	JurisdictionRisk *JurisdictionRisk `json:"jurisdiction_risk,omitempty"`
}

// =============================================================================
// GET /v1/portfolio - List monitored entities
// =============================================================================
//
// Returns List[PortfolioEntityResponse], cursor-paginated by entity id
// (?after=&limit=), ordered id ascending — see the "Pagination" contract.
// Optional filters: q (name substring), min_score, max_score. Full extraction
// of a large portfolio belongs to the async export/batch job, not GET paging.

// PortfolioEntityResponse is a single entity in the portfolio.
type PortfolioEntityResponse struct {
	// Entity ID
	ID int64 `json:"id"`

	// Full name being monitored
	FullName string `json:"full_name"`

	// Date of birth (YYYY-MM-DD) if known
	DateOfBirth string `json:"date_of_birth,omitempty"`

	// Country code (ISO 3166-1 alpha-2) if known
	Country string `json:"country,omitempty"`

	// Entity type: individual or entity
	EntityType string `json:"entity_type"`

	// Highest match score from last screening
	HighestScore float64 `json:"highest_score"`

	// Mean match score from last screening
	AverageScore float64 `json:"average_score"`

	// When this entity was last screened
	LastScreenedAt string `json:"last_screened_at"`

	// When this entity was first enrolled
	CreatedAt string `json:"created_at"`
}

// PortfolioAddRequest is the input for adding or updating a portfolio entity.
type PortfolioAddRequest struct {
	// Full name to monitor (required)
	FullName string `json:"full_name"`

	// Date of birth in YYYY-MM-DD format (optional)
	DateOfBirth string `json:"date_of_birth,omitempty"`

	// Country code ISO 3166-1 alpha-2 (optional)
	Country string `json:"country,omitempty"`
}

// PortfolioStatsResponse is the count companion to GET /v1/portfolio. Answers
// "how many entities am I monitoring, and how many are high-risk?" without
// paging the whole portfolio. Honors the same q/min_score/max_score filters as
// the list endpoint, so the count matches the filtered view.
type PortfolioStatsResponse struct {
	// Total active entities matching the (optional) filters.
	Total int `json:"total"`

	// ByScoreBand counts entities by highest match score (0–100):
	// "95-100", "90-94", "80-89", "below-80".
	ByScoreBand map[string]int `json:"by_score_band"`
}

// =============================================================================
// GET /v1/export - Compliance export of screening decisions
// =============================================================================

// ExportRecord is a single screening decision for compliance export.
// It contains the complete request, response, and metadata for regulatory
// audit trails, across ALL screening channels (see Channel).
type ExportRecord struct {
	// Row ID
	ID int64 `json:"id"`

	// Trace ID from the original screening request
	TraceID string `json:"trace_id"`

	// RequestID is the per-call HTTP request ID of the original screening,
	// stored alongside TraceID. Lets an operator pivot from a request_id to
	// this record. Empty outside the api channel and for screenings persisted
	// before this field existed.
	RequestID string `json:"request_id,omitempty"`

	// Client that owns this record
	ClientID string `json:"client_id"`

	// Channel is the screening pathway that produced this record: "api"
	// (POST /v1/screen), "batch" (POST /v1/batch items), or "monitoring"
	// (a sanctions-list change reverse-screened against the portfolio).
	// Records persisted before channels existed report "api".
	Channel string `json:"channel"`

	// ScreenedName is the name that was screened, surfaced first-class so
	// exports can be filtered by entity. For monitoring records the
	// direction is inverted: this is the changed sanctions-list name that
	// was screened against your portfolio.
	ScreenedName string `json:"screened_name,omitempty"`

	// Request is the verbatim audit payload of the screening inputs. Its
	// shape is channel-dependent: for "api" it is a ScreenRequest; for
	// "batch" it carries the item's fields under the same JSON keys (name,
	// date_of_birth, country, entity_type, id_number, id_type, lists); for
	// "monitoring" it describes the list change (source, uid, name,
	// change_type, publish_id, job_id).
	Request json.RawMessage `json:"request"`

	// Response is the verbatim audit payload of the screening outcome. For
	// "api" it is a ScreenResponse (incl. list_versions); for "batch" it
	// carries matches, match_count, highest_score, average_score, and
	// list_versions; for "monitoring" it carries the matched portfolio
	// entities (PII blanked for Confidential Portfolio rows), match_count,
	// highest_score, publish_id, and source.
	Response json.RawMessage `json:"response"`

	// OFAC list version active at screening time
	OFACListVersion *OFACListInfo `json:"ofac_list_version,omitempty"`

	// When the screening occurred (RFC 3339)
	ScreenedAt string `json:"screened_at"`
}

// OFACListInfo identifies the OFAC sanctions list version.
//
// Deprecated: prefer ListVersion, which carries the same data plus the
// canonical list identifier. Retained while ScreenResponse.OFACListVersion
// is being phased out.
type OFACListInfo struct {
	ID          int    `json:"id"`
	PublishDate string `json:"publish_date"`
	RecordCount int    `json:"record_count"`
}

// ListVersion identifies the publisher snapshot active at screening time for
// a single sanctions list. ScreenResponse.ListVersions surfaces one entry
// per list named in ListsScreened so examiners can pin a screening decision
// to the exact list snapshots that produced it.
type ListVersion struct {
	// List is the canonical identifier matching the values accepted in
	// ScreenRequest.Lists (e.g., "ofac", "uk", "eu", "france", "belgium",
	// "netherlands", "un", "canada", "australia").
	List string `json:"list"`

	// PublishID is the source-list publish_info row identifier — the audit
	// pointer back to the imported file in <list>.publish_info.
	PublishID int `json:"publish_id"`

	// PublishDate is the date the publisher released this version
	// (YYYY-MM-DD), taken verbatim from the source list.
	PublishDate string `json:"publish_date"`

	// RecordCount is the number of entries in this version.
	RecordCount int `json:"record_count"`
}

// GET /v1/export/bulk returns List[ExportRecord], cursor-paginated by screening
// row id (?after=&limit=) within a required from/to (YYYY-MM-DD) date range,
// ordered id ascending — see the "Pagination" contract. Optional filters:
// ?name= restricts to records whose screened_name matches case-insensitively
// (entity-centric audit: one customer's screening history across every
// channel), and ?channel= restricts to one of api|batch|monitoring. limit is hard-capped at
// MaxPageLimit; a full audit pull belongs to the async export job. There is no
// total. Requires Standard tier or higher; client isolation enforced.
//
// Output format: the audit/export endpoints — GET /v1/export, GET
// /v1/export/bulk, and GET /v1/alerts/activity — accept ?format=json (default)
// or ?format=csv. CSV is a flat, examiner-friendly table, one row per record,
// with a header row; an unrecognized format value is a 400 invalid_parameter.
// Because a CSV body cannot carry the JSON meta.disclaimer, CSV responses
// surface the liability disclaimer in the X-Noble-Disclaimer response header
// and set Content-Disposition so the download is saved as a named .csv file.
// The full nested match detail is JSON-only; the CSV columns summarize each
// decision (top match, list version, request attributes).

// =============================================================================
// POST /v1/export/replay - Recompute a stored screening against its historical
// list snapshot and compare
// =============================================================================
//
// Replay is the examiner-grade proof that the system reproduces a recorded
// decision: it loads a stored screening by trace_id (or request_id), re-runs
// the screening engine pinned to the sanctions-list state at the original
// screening instant (as_of = the record's screened_at), and reports a
// field-by-field comparison. Requires Standard tier or higher; client
// isolation enforced (you can only replay your own records).
//
// Scope: channel "api" records only — batch and monitoring records store
// channel-specific payload shapes and are rejected with
// replay_unsupported_channel. The comparison deliberately excludes what
// cannot be deterministically reproduced, and names each exclusion in
// caveats rather than hiding it:
//
//   - internal watchlist matches (WL- UIDs): the tenant watchlist is mutable
//     tenant data with no version history; stored watchlist matches are
//     excluded from comparison and the replay does not re-screen it.
//   - deep_screen influence: LLM variation expansion is non-deterministic;
//     when the stored request used deep_screen, the replay runs the
//     deterministic engine only. Per-match scores are not compared, and
//     match identity is compared asymmetrically: stored matches absent from
//     the replay are attributed to variation expansion (caveat, not drift),
//     while replay-only matches are still reported as differences.
//   - volatile identifiers: trace_id, request_id, screened_at, screening_id,
//     alert_id, and LLM summary prose are never compared.
//   - OFAC child detail: see ScreenRequest.AsOf's fidelity boundary.

// ReplayRequest selects the stored screening to replay. Exactly one of
// TraceID or RequestID is required; TraceID wins when both are present.
type ReplayRequest struct {
	// TraceID of the stored screening (ScreenResponse.TraceID).
	TraceID string `json:"trace_id,omitempty"`

	// RequestID of the stored screening (ScreenResponse.RequestID).
	RequestID string `json:"request_id,omitempty"`
}

// ReplayVerdict is the outcome of a replay comparison.
type ReplayVerdict string

const (
	// ReplayVerdictMatch: every compared field reproduced exactly.
	ReplayVerdictMatch ReplayVerdict = "match"

	// ReplayVerdictMismatch: at least one compared field differed — see
	// Differences. A mismatch is a finding to investigate (list data
	// mutated outside the import path, or an engine behavior change), not
	// necessarily an error in the stored record.
	ReplayVerdictMismatch ReplayVerdict = "mismatch"
)

// ReplayDifference is one compared field that did not reproduce.
type ReplayDifference struct {
	// Field is a dotted path identifying what differed, e.g.
	// "list_versions.ofac.publish_id", "matches.uids",
	// "matches.12345.score", "summary.verdict", "match_count".
	Field string `json:"field"`

	// Stored is the value in the audit record.
	Stored string `json:"stored"`

	// Replayed is the value the re-run produced.
	Replayed string `json:"replayed"`
}

// ReplayResponse reports a point-in-time replay of a stored screening.
type ReplayResponse struct {
	// TraceID of the replayed record.
	TraceID string `json:"trace_id"`

	// ScreeningID is the stored audit row id.
	ScreeningID int64 `json:"screening_id"`

	// ScreenedAt is when the original screening ran (RFC 3339). The replay
	// pins the engine to the sanctions-list state at exactly this instant.
	ScreenedAt string `json:"screened_at"`

	// Verdict is the comparison outcome across every compared field.
	Verdict ReplayVerdict `json:"verdict"`

	// Differences lists each compared field that failed to reproduce.
	// Empty when Verdict is "match".
	Differences []ReplayDifference `json:"differences,omitempty"`

	// Caveats names each comparison exclusion that applied to this record
	// (Caveat* constants), so the verdict's scope is explicit.
	Caveats []string `json:"caveats,omitempty"`

	// Stored is the verbatim audit response payload of the original
	// screening (a ScreenResponse).
	Stored json.RawMessage `json:"stored"`

	// Replayed is the full re-computed screening response. Its own volatile
	// fields (trace_id, screened_at, ...) describe the replay run, not the
	// original.
	Replayed *ScreenResponse `json:"replayed"`

	Meta ResponseMeta `json:"meta"`
}

// Replay comparison caveats (ReplayResponse.Caveats).
const (
	// CaveatWatchlistExcluded: the stored screening contained internal
	// watchlist matches; they were excluded from comparison.
	CaveatWatchlistExcluded = "watchlist_matches_excluded"

	// CaveatDeepScreenScoresNotCompared: the stored request used deep_screen;
	// match identity was compared but per-match scores, average_score, and
	// summary.verdict were not.
	CaveatDeepScreenScoresNotCompared = "deep_screen_scores_not_compared"

	// CaveatDeepScreenMatchesNotReproduced: the stored deep_screen run
	// contains matches absent from the deterministic replay. They are
	// attributed to LLM variation expansion — which can discover matches the
	// deterministic engine cannot find from the original name — and excluded
	// from the identity comparison rather than reported as drift. Matches
	// found only by the replay are still reported as a difference.
	CaveatDeepScreenMatchesNotReproduced = "deep_screen_matches_not_reproduced"

	// CaveatOFACChildDetailCurrent: the record contains OFAC matches, whose
	// alias/address/identifier child data replays at current state (see
	// ScreenRequest.AsOf).
	CaveatOFACChildDetailCurrent = "ofac_child_detail_current_state"
)

// =============================================================================
// POST /v1/keys - Provision a new API key
// =============================================================================
//
// Behavior depends on the server's signup_mode:
//
//   open         - anyone may create a free-tier key; invite_code is optional
//                  and, when supplied, overrides the tier per the invite row.
//   invite_only  - invite_code is required; tier comes from the invite row.
//   closed       - all signups return 403 with code=signup_closed.
//
// signup_mode is a server configuration value (SIGNUP_MODE env var). It is
// not part of the request — clients discover the current mode through the
// 403 (signup_closed) or 400 (invite_code_required) error responses.

// TermsVersion is the current version of Noble Sight's Terms of Service.
// Bump this when terms change to require re-acceptance on new key creation.
const TermsVersion = "1.0"

// CodeTermsNotAccepted is the error code when accept_terms is not true.
const CodeTermsNotAccepted = "terms_not_accepted"

// Signup mode constants. These govern whether POST /v1/keys is reachable and
// whether it requires an invite code. The value is set via SIGNUP_MODE env var.
const (
	SignupModeOpen       = "open"
	SignupModeInviteOnly = "invite_only"
	SignupModeClosed     = "closed"
)

// ValidSignupModes is the immutable set of accepted signup_mode values.
// Used at startup to validate the SIGNUP_MODE config and fail loud on typos.
var ValidSignupModes = map[string]bool{
	SignupModeOpen:       true,
	SignupModeInviteOnly: true,
	SignupModeClosed:     true,
}

// CreateKeyRequest is the input for provisioning a new API key.
type CreateKeyRequest struct {
	// Unique identifier for the client organization (required, 1-100 chars)
	ClientID string `json:"client_id"`

	// Human-readable label for the key (optional, max 255 chars)
	Name string `json:"name,omitempty"`

	// Must be true to create a key. Indicates acceptance of Noble Sight's
	// Terms of Service (https://noblesight.io/terms) and acknowledgment that
	// screening results are informational only and do not constitute legal
	// or compliance advice.
	AcceptTerms bool `json:"accept_terms"`

	// InviteCode redeems an invite issued by Noble Sight. Required when the
	// server is in signup_mode=invite_only. Optional in signup_mode=open
	// (when supplied, the tier and scopes on the invite override the free-tier
	// defaults — used for promo codes, design-partner trials, and sales-led
	// trials). Format: noble_inv_ + 20 hex characters.
	InviteCode string `json:"invite_code,omitempty"`
}

// CreateKeyResponse is returned after successfully creating an API key.
type CreateKeyResponse struct {
	// Plaintext API key (shown once — store securely)
	Key string `json:"key"`

	// First 16 characters of the key for identification
	KeyPrefix string `json:"key_prefix"`

	// Tier assigned to this key
	Tier string `json:"tier"`

	// Daily rate limit (free tier)
	RateLimitPerDay int `json:"rate_limit_per_day"`

	// Version of the Terms of Service accepted
	TermsVersion string `json:"terms_version"`
}

// =============================================================================
// POST /v1/admin/keys — Mint a comped or trial API key (internal admin only)
// =============================================================================
//
// Reserved for internal admin tooling (provisioning design-partner trials,
// pilot comps, event comps). Reachable in Phase 1 only via the localhost
// interface of the noble-server pod — `kubectl port-forward` from an
// operator who already has GKE IAM access. Phase 2 will gate `/v1/admin/*`
// via Google IAP and override `granted_by` from the IAP-verified JWT email
// so callers cannot misrepresent themselves; the request/response shapes
// below remain unchanged.

// AdminCreateKeyRequest mints a comped/trial API key for a customer.
type AdminCreateKeyRequest struct {
	// Customer identifier (required, 1-100 chars)
	ClientID string `json:"client_id"`

	// Human-readable label for the key (optional, max 255 chars)
	Name string `json:"name,omitempty"`

	// Tier: free, standard, premium, enterprise. Trials are typically enterprise.
	Tier string `json:"tier"`

	// Trial duration in whole days. Zero/omitted means no expiry.
	// 30 = 30-day trial, 180 = 6-month trial, 365 = year-long comp.
	TrialDays int `json:"trial_days,omitempty"`

	// Comp category for cohort analysis: design_partner, pilot, evaluation, event.
	// Required when trial_days > 0.
	CompCategory string `json:"comp_category,omitempty"`

	// Free-text justification for the comp (required). Surfaced in audit logs
	// and the eventual admin-key list endpoint.
	CompReason string `json:"comp_reason"`

	// Operator identity for the audit trail (e.g., gcloud account email).
	// Required in Phase 1; overridden by IAP-verified email in Phase 2.
	GrantedBy string `json:"granted_by"`

	// Contact email — recorded in slog audit only, not the api_key row.
	Email string `json:"email,omitempty"`
}

// AdminCreateKeyResponse echoes the audit metadata and returns the plaintext
// key ONCE. Operators copy the key from this response; it is not retrievable
// later (only the SHA-256 hash is stored).
type AdminCreateKeyResponse struct {
	// Plaintext API key — shown once, store securely.
	Key string `json:"key"`

	// First 16 characters of the key for identification.
	KeyPrefix string `json:"key_prefix"`

	// Customer identifier this key was minted for.
	ClientID string `json:"client_id"`

	// Tier assigned to this key.
	Tier string `json:"tier"`

	// When the trial expires (RFC 3339). Empty when no trial.
	TrialEndsAt string `json:"trial_ends_at,omitempty"`

	// Comp category recorded for this key.
	CompCategory string `json:"comp_category,omitempty"`

	// Free-text justification recorded for this key.
	CompReason string `json:"comp_reason"`

	// Operator identity recorded for this key.
	CompGrantedBy string `json:"comp_granted_by"`
}

// Error codes for admin endpoints.
const (
	CodeReasonRequired        = "comp_reason_required"
	CodeCategoryRequired      = "comp_category_required"
	CodeGrantedByRequired     = "granted_by_required"
	CodeInvalidTrialDays      = "invalid_trial_days"
	CodeInvalidCategory       = "invalid_comp_category"
	CodeInvalidAdminTier      = "invalid_tier"
	CodeInviteNotesTooLong    = "invite_notes_too_long"
	CodeInvalidMaxRedemptions = "invalid_max_redemptions"
	CodeInvalidExpiresAt      = "invalid_expires_at"
)

// =============================================================================
// /v1/admin/invites — Mint and manage invite codes (internal admin only)
// =============================================================================
//
// Internal admin tooling for provisioning controlled signup access. Same
// auth posture as /v1/admin/keys: localhost-only in Phase 1 (reachable via
// kubectl port-forward), Google IAP in Phase 2. The created_by field is
// captured from the request in Phase 1 and overridden by the IAP-verified
// JWT email in Phase 2.
//
// Issuing one of these codes is the *only* way new customers (post-Royal
// Bank) enter the system while signup_mode=invite_only. Every code is a
// row with a created_by + timestamp — the auditable artifact a regulator
// reviews when asking "who granted this customer access?"

// AdminCreateInviteRequest mints a new invite code.
type AdminCreateInviteRequest struct {
	// Tier granted to keys redeemed from this invite (required).
	// Values: free, standard, premium, enterprise.
	Tier string `json:"tier"`

	// MaxRedemptions caps how many keys can be minted from this code.
	// Defaults to 1. Use higher values for promo codes ("BLACKFRIDAY: 100 redemptions").
	MaxRedemptions int `json:"max_redemptions,omitempty"`

	// ExpiresAt is when the code stops being redeemable (RFC 3339).
	// Empty/omitted = never expires. Use for time-limited promos.
	ExpiresAt string `json:"expires_at,omitempty"`

	// CreatedBy is the operator identity recorded for audit (e.g., gcloud
	// account email). Required in Phase 1; overridden by IAP email in Phase 2.
	CreatedBy string `json:"created_by"`

	// Notes is free-text context for the audit trail (e.g., "Royal Bank beta",
	// "Q1 conference comp"). Max 500 chars. Surfaced in the admin invite list.
	Notes string `json:"notes,omitempty"`

	// Scopes are capability scopes inherited by every key minted from this
	// invite. Omit or pass empty for the default ["api"]. Pass ["api","mcp"]
	// for design-partner invites that need MCP from day one. Must always
	// include "api" — every key needs REST access.
	Scopes []string `json:"scopes,omitempty"`
}

// AdminCreateInviteResponse echoes the audit metadata and returns the plaintext
// code ONCE. Codes are reversible (stored in plaintext so customers can be
// re-sent their code if lost), but operators should still capture this response.
type AdminCreateInviteResponse struct {
	// Plaintext invite code (noble_inv_ + 20 hex chars).
	Code string `json:"code"`

	// Unique invite ID (UUID).
	ID string `json:"id"`

	// Tier granted by this invite.
	Tier string `json:"tier"`

	// Maximum redemptions allowed.
	MaxRedemptions int `json:"max_redemptions"`

	// Expiry timestamp (RFC 3339), empty when never expires.
	ExpiresAt string `json:"expires_at,omitempty"`

	// Operator who minted the invite.
	CreatedBy string `json:"created_by"`

	// Free-text notes recorded for this invite.
	Notes string `json:"notes,omitempty"`

	// When the invite was created (RFC 3339).
	CreatedAt string `json:"created_at"`

	// Scopes inherited by every key minted from this invite.
	Scopes []string `json:"scopes"`
}

// InviteResponse is a single invite in admin listings. The plaintext code is
// included — invites are not secret credentials (the redeemed API key is).
type InviteResponse struct {
	ID              string   `json:"id"`
	Code            string   `json:"code"`
	Tier            string   `json:"tier"`
	MaxRedemptions  int      `json:"max_redemptions"`
	RedemptionsUsed int      `json:"redemptions_used"`
	ExpiresAt       string   `json:"expires_at,omitempty"`
	Status          string   `json:"status"`
	CreatedBy       string   `json:"created_by"`
	Notes           string   `json:"notes,omitempty"`
	CreatedAt       string   `json:"created_at"`
	RevokedAt       string   `json:"revoked_at,omitempty"`
	Scopes          []string `json:"scopes"`
}

// InviteListResponse wraps a list of invites for admin tooling.
type InviteListResponse struct {
	Total   int              `json:"total"`
	Invites []InviteResponse `json:"invites"`
}

// MaxInviteNotesLength caps the notes field on an invite to prevent abuse.
const MaxInviteNotesLength = 500

// =============================================================================
// POST /v1/admin/keys/scopes - Grant or revoke capability scopes on an
// existing API key. Localhost-only. Writes an append-only audit row to
// customers.api_key_scope_change capturing before/after, operator, and
// reason — examiners reconstruct capability history from that table.
// =============================================================================

// AdminChangeKeyScopesRequest mutates the scopes on an existing API key.
// The change is transactional: the UPDATE on customers.api_key and the
// INSERT on customers.api_key_scope_change either both commit or neither.
type AdminChangeKeyScopesRequest struct {
	// KeyID is customers.api_key.id of the target key (required).
	KeyID int64 `json:"key_id"`

	// Add lists scopes to grant. Already-present scopes are silently ignored.
	// Recognized values today: "mcp". ("api" cannot be added — every key has
	// it by construction.)
	Add []string `json:"add,omitempty"`

	// Remove lists scopes to revoke. Already-absent scopes are silently
	// ignored. "api" cannot be removed — every key must retain REST access.
	Remove []string `json:"remove,omitempty"`

	// Reason is the human-readable justification recorded on the audit row
	// (required). Examples: "Royal Bank MCP design-partner enrollment",
	// "revoked per support ticket #1234". Max 500 chars.
	Reason string `json:"reason"`

	// ChangedBy is the operator identity recorded for audit (required).
	// Phase 1: gcloud account email passed in the request body. Phase 2:
	// overridden by the IAP-verified email and the request body field is
	// ignored.
	ChangedBy string `json:"changed_by"`
}

// AdminChangeKeyScopesResponse returns the before/after state and the
// audit row ID so operators can cite it in change tickets.
type AdminChangeKeyScopesResponse struct {
	KeyID        int64    `json:"key_id"`
	ClientID     string   `json:"client_id"`
	KeyPrefix    string   `json:"key_prefix"`
	ScopesBefore []string `json:"scopes_before"`
	ScopesAfter  []string `json:"scopes_after"`
	AuditID      int64    `json:"audit_id"`
	ChangedAt    string   `json:"changed_at"` // RFC 3339
}

// =============================================================================
// GET /v1/admin/ownership/pending - 50 Percent Rule extraction review queue.
// Localhost-only. Lists LLM-extracted ownership assertions (#63 Phase C)
// awaiting human review: every relation the list-text extractor persisted to
// ownership.extracted_relation with its verbatim source quote. Reviewing
// these is how an operator validates the extraction layer feeding the
// ownership graph.
// =============================================================================

// OwnershipPendingResponse lists extracted ownership relations awaiting
// review, oldest first.
type OwnershipPendingResponse struct {
	// Total is the number of relations awaiting review (may exceed
	// len(Relations) when the queue is longer than the page cap).
	Total     int                        `json:"total"`
	Relations []OwnershipPendingRelation `json:"relations"`
}

// OwnershipPendingRelation is one LLM-extracted ownership assertion, exactly
// as persisted to the audit store before it became a graph edge.
type OwnershipPendingRelation struct {
	// ID is the assertion's stable identifier in ownership.extracted_relation.
	ID int64 `json:"id"`

	// List is the sanctions list whose published text the assertion came
	// from ("france", "uk", "un", "eu").
	List string `json:"list"`

	// UID is the source-native designation ID of the list record.
	UID string `json:"uid"`

	// Subject is the list record's subject name (the designated party whose
	// entry carried the text).
	Subject string `json:"subject"`

	// OwnerText and OwnedText are the owner and owned entity names exactly
	// as written in the source text.
	OwnerText string `json:"owner_text"`
	OwnedText string `json:"owned_text"`

	// RelType is "ownership" or "control".
	RelType string `json:"rel_type"`

	// Percent is the ownership percentage, present only when the source
	// text states a number.
	Percent *float64 `json:"percent,omitempty"`

	// Quote is the verbatim minimal substring of the list text the
	// assertion was extracted from — the audit evidence.
	Quote string `json:"quote"`

	// OwnerKey and OwnedKey are the graph entity keys the names resolved
	// to; empty when unresolved.
	OwnerKey string `json:"owner_key,omitempty"`
	OwnedKey string `json:"owned_key,omitempty"`

	// Model is the LLM that performed the extraction.
	Model string `json:"model"`

	// ListPublishID is the list snapshot the text came from.
	ListPublishID int `json:"list_publish_id"`

	// ExtractedAt is when the assertion was extracted (RFC 3339).
	ExtractedAt string `json:"extracted_at"`
}

// MaxOwnershipPendingPage caps one review-queue page.
const MaxOwnershipPendingPage = 500

// =============================================================================
// GET /v1/usage - Check API usage for the current key
// =============================================================================

// UsageResponse returns the current usage stats for the authenticated API key.
type UsageResponse struct {
	// Tier of the authenticated key
	Tier string `json:"tier"`

	// Requests used in the current window
	Used int `json:"used"`

	// Total request limit for the current window
	Limit int `json:"limit"`

	// Requests remaining in the current window
	Remaining int `json:"remaining"`

	// Rate limit window: "day" (free) or "month" (paid)
	Window string `json:"window"`

	// Trial details — present only when this key is a comped/trial key.
	// Customers and customer dashboards use this to show "your trial ends in N days".
	Trial *TrialInfo `json:"trial,omitempty"`
}

// TrialInfo describes the trial window for a comped API key.
// Trial keys receive full enterprise-tier access until ends_at, after which
// a daily job flips the key's status to "expired" and subsequent requests
// return 402 Payment Required with code=trial_expired.
type TrialInfo struct {
	// When the trial expires (RFC 3339, UTC)
	EndsAt string `json:"ends_at"`

	// Days remaining until expiry (0 on the day of expiry; never negative)
	DaysRemaining int `json:"days_remaining"`

	// Comp category for the trial: design_partner, pilot, evaluation, event
	Category string `json:"category,omitempty"`
}

// =============================================================================
// GET /v1/alerts - List alerts for the authenticated tenant
// =============================================================================
//
// Returns List[AlertResponse], cursor-paginated by alert id (?after=&limit=),
// ordered id ascending — see the "Pagination" contract. Filters (all optional):
// status, source, min_score, max_score, assigned_to, from, to (YYYY-MM-DD).
// There is no total; use GET /v1/alerts/stats for queue counts.
//
// =============================================================================
// GET /v1/alerts/{id} - Single alert with full detail
// =============================================================================
//
// Returns a single AlertResponse including the match payload and the full case
// activity timeline (Activity, oldest first). 404 if the id is unknown or not
// owned by the caller's tenant.

// AlertResponse is a single alert in list or detail views.
type AlertResponse struct {
	// Unique alert identifier
	ID int64 `json:"id"`

	// The name that was screened
	ScreenedName string `json:"screened_name"`

	// Combined match score (0-100)
	Score float64 `json:"score"`

	// Number of sanctions matches
	MatchCount int `json:"match_count"`

	// How the alert originated: "api" (POST /v1/screen match), "batch"
	// (POST /v1/batch item), "monitoring" (portfolio monitoring delta),
	// "ownership" (a "blocked" 50 Percent Rule determination — see
	// ScreenResponse.Ownership; for these, score is always 100 and the
	// determination evidence is in match_details), or "console" (legacy).
	Source string `json:"source"`

	// Alert lifecycle status
	Status string `json:"status"`

	// Analyst assigned to this alert (email or "apikey:<prefix>")
	AssignedTo string `json:"assigned_to,omitempty"`

	// Structured resolution reason (only when closed)
	Reason string `json:"reason,omitempty"`

	// Disposition records what action was taken for true matches.
	// Values: "blocked", "rejected", "permitted_gl", "no_action_required".
	Disposition string `json:"disposition,omitempty"`

	// GLCitation documents which general license permits the transaction.
	GLCitation string `json:"gl_citation,omitempty"`

	// Whether the four-eyes rule applies to this alert (score >= 90)
	FourEyesRequired bool `json:"four_eyes_required"`

	// SLA deadline (RFC 3339). Score >= 90: 24h, others: 72h from assignment.
	DueAt string `json:"due_at,omitempty"`

	// Whether the SLA deadline has passed for an open alert
	Overdue bool `json:"overdue"`

	// SDN match details (present when matched against a specific SDN entry)
	SdnUID       int    `json:"sdn_uid,omitempty"`
	SdnFirstName string `json:"sdn_first_name,omitempty"`
	SdnLastName  string `json:"sdn_last_name,omitempty"`

	// Full match payload (JSON array of all screening matches)
	MatchPayload json.RawMessage `json:"match_payload,omitempty"`

	// Portfolio entity (present when alert is linked to a monitored entity)
	Entity *AlertEntityResponse `json:"entity,omitempty"`

	// Case activity timeline (present in detail view, omitted in list)
	Activity []AlertActivityResponse `json:"activity,omitempty"`

	// When the alert was created (RFC 3339)
	CreatedAt string `json:"created_at"`
}

// AlertEntityResponse is the portfolio entity linked to an alert.
type AlertEntityResponse struct {
	FullName    string `json:"full_name"`
	DateOfBirth string `json:"date_of_birth,omitempty"`
	Country     string `json:"country,omitempty"`
	EntityType  string `json:"entity_type"`
}

// AlertActivityResponse is a single entry in the alert's audit trail.
type AlertActivityResponse struct {
	// Who performed the action (email, "apikey:<prefix>", or
	// "system:monitoring" for list_event entries)
	Actor string `json:"actor"`

	// Action type: "status_change", "assigned", "note", "escalated",
	// "reopened", or "list_event". A list_event entry records a sanctions
	// list update that matched this alert's entity while the alert was open
	// — the same person re-designated or modified on the same list — so the
	// timeline shows every designation event during the alert's life; the
	// note carries the change type, list, publish id, and score. (A
	// designation on a different list raises its own alert instead.)
	Action string `json:"action"`

	// Previous status (for status_change actions)
	FromStatus string `json:"from_status,omitempty"`

	// New status (for status_change actions)
	ToStatus string `json:"to_status,omitempty"`

	// Structured reason code (for resolution actions)
	Reason string `json:"reason,omitempty"`

	// Disposition (for true match closures)
	Disposition string `json:"disposition,omitempty"`

	// General license citation (for permitted_gl dispositions)
	GLCitation string `json:"gl_citation,omitempty"`

	// Free-text investigation note
	Note string `json:"note,omitempty"`

	// When this action occurred (RFC 3339)
	Time string `json:"time"`

	// ID is the case-activity row id. Present in the activity export
	// (GET /v1/alerts/activity) where it is the cursor for ?after=; omitted in
	// the alert-detail timeline.
	ID int64 `json:"id,omitempty"`

	// Alert ID (present in activity export, omitted in alert detail)
	AlertID int64 `json:"alert_id,omitempty"`
}

// =============================================================================
// POST /v1/alerts/{id}/resolve - Resolve an alert with a structured reason
// =============================================================================

// AlertResolveRequest is the input for resolving an alert.
type AlertResolveRequest struct {
	// Target status: "closed_false_positive" or "closed_true_match"
	Status string `json:"status"`

	// Structured reason code (required)
	Reason string `json:"reason"`

	// Disposition records what action was taken for true matches (required when status=closed_true_match).
	// Values: "blocked", "rejected", "permitted_gl", "no_action_required".
	// OFAC requires filing blocking/reject reports within 10 business days.
	Disposition string `json:"disposition,omitempty"`

	// GLCitation documents which general license permits the transaction (required when disposition=permitted_gl).
	// Example: "GL 8A — Iran humanitarian trade" or "Russia GL 25G".
	GLCitation string `json:"gl_citation,omitempty"`

	// Optional investigation note
	Note string `json:"note,omitempty"`
}

// =============================================================================
// POST /v1/alerts/{id}/assign - Assign an alert to an analyst
// =============================================================================

// AlertAssignRequest is the input for assigning an alert.
type AlertAssignRequest struct {
	// Email of the analyst to assign to (required)
	AssignTo string `json:"assign_to"`
}

// =============================================================================
// POST /v1/alerts/{id}/review - Start investigation (assigned → under_review)
// =============================================================================

// AlertReviewRequest is the input for starting investigation on an alert.
// Unlike resolve, no reason code is required — the analyst is beginning work.
type AlertReviewRequest struct {
	// Optional note capturing initial observations
	Note string `json:"note,omitempty"`
}

// =============================================================================
// POST /v1/alerts/{id}/notes - Add an investigation note
// =============================================================================

// AlertNoteRequest is the input for adding a note to an alert.
type AlertNoteRequest struct {
	// Investigation note text (required, max 2000 chars)
	Note string `json:"note"`
}

// =============================================================================
// POST /v1/alerts/{id}/escalate - Escalate an alert
// =============================================================================

// AlertEscalateRequest is the input for escalating an alert.
type AlertEscalateRequest struct {
	// Optional note explaining the escalation
	Note string `json:"note,omitempty"`
}

// =============================================================================
// Alert Constants
// =============================================================================

// Alert statuses define the lifecycle of an alert.
// Both the console UI and REST API enforce the same transitions.
const (
	AlertStatusNew             = "new"
	AlertStatusAssigned        = "assigned"
	AlertStatusUnderReview     = "under_review"
	AlertStatusEscalated       = "escalated"
	AlertStatusClosedFP        = "closed_false_positive"
	AlertStatusClosedTrueMatch = "closed_true_match"
)

// Alert resolution reason codes. Structured reasons are required for closing
// an alert and serve as the auditable justification an OFAC examiner will review.
const (
	ReasonFPNameOnly           = "false_positive_name_only"
	ReasonFPDOBMismatch        = "false_positive_dob_mismatch"
	ReasonFPCountryMismatch    = "false_positive_country_mismatch"
	ReasonFPEntityTypeMismatch = "false_positive_entity_type_mismatch"
	ReasonFPOther              = "false_positive_other"
	ReasonTrueMatch            = "true_match"
)

// Disposition constants record what action was taken on a confirmed true match.
// OFAC requires blocking/reject reports within 10 business days (31 C.F.R. Part 501).
const (
	DispositionBlocked          = "blocked"
	DispositionRejected         = "rejected"
	DispositionPermittedGL      = "permitted_gl"
	DispositionNoActionRequired = "no_action_required"
)

// validReasons is the immutable set of accepted resolution reason codes.
// Unexported to prevent runtime mutation — compliance state machine integrity.
var validReasons = map[string]bool{
	ReasonFPNameOnly:           true,
	ReasonFPDOBMismatch:        true,
	ReasonFPCountryMismatch:    true,
	ReasonFPEntityTypeMismatch: true,
	ReasonFPOther:              true,
	ReasonTrueMatch:            true,
}

// IsValidReason reports whether r is an accepted resolution reason code.
func IsValidReason(r string) bool { return validReasons[r] }

// validDispositions is the immutable set of accepted disposition values for true matches.
var validDispositions = map[string]bool{
	DispositionBlocked:          true,
	DispositionRejected:         true,
	DispositionPermittedGL:      true,
	DispositionNoActionRequired: true,
}

// IsValidDisposition reports whether d is an accepted disposition value.
func IsValidDisposition(d string) bool { return validDispositions[d] }

// validTransitions defines the allowed status transitions for alerts.
// Alerts cannot be closed without passing through under_review — this ensures
// every closure has an investigation step in the audit trail. Closed alerts
// can be reopened to under_review when an examiner finds a premature closure.
// Escalated alerts can be sent back to under_review for further investigation.
// Unexported to prevent runtime mutation — compliance state machine integrity.
var validTransitions = map[string]map[string]bool{
	AlertStatusNew:             {AlertStatusAssigned: true, AlertStatusEscalated: true},
	AlertStatusAssigned:        {AlertStatusUnderReview: true, AlertStatusEscalated: true},
	AlertStatusUnderReview:     {AlertStatusEscalated: true, AlertStatusClosedFP: true, AlertStatusClosedTrueMatch: true},
	AlertStatusEscalated:       {AlertStatusUnderReview: true, AlertStatusClosedFP: true, AlertStatusClosedTrueMatch: true},
	AlertStatusClosedFP:        {AlertStatusUnderReview: true},
	AlertStatusClosedTrueMatch: {AlertStatusUnderReview: true},
}

// IsValidTransition reports whether transitioning from one status to another is allowed.
func IsValidTransition(from, to string) bool {
	allowed, ok := validTransitions[from]
	return ok && allowed[to]
}

// FourEyesScoreThreshold is the minimum score (0-100) that triggers the
// four-eyes rule: a different analyst must close the alert than the one
// who last moved it through the workflow.
const FourEyesScoreThreshold = 90

// SLA deadlines based on alert score.
const (
	SLAHighScoreHours    = 24 // score >= FourEyesScoreThreshold
	SLADefaultScoreHours = 72 // score < FourEyesScoreThreshold
)

// MaxNoteLength is the maximum character length for investigation notes.
const MaxNoteLength = 2000

// AlertService sentinel errors. Defined here (alongside the interface in server.go)
// so that both internal/alert and HTTP handlers share the same error values.
var (
	ErrAlertNotFound            = errors.New("alert not found")
	ErrAlertInvalidReason       = errors.New("invalid reason code")
	ErrAlertInvalidTransition   = errors.New("invalid status transition")
	ErrAlertInvalidDisposition  = errors.New("invalid disposition")
	ErrAlertDispositionRequired = errors.New("disposition required for true match closure")
	ErrAlertGLCitationRequired  = errors.New("gl_citation required when disposition is permitted_gl")
	ErrAlertFourEyes            = errors.New("four-eyes required: a different analyst must close high-score alerts")
	ErrAlertConcurrentModified  = errors.New("alert status changed concurrently")
	ErrAlertInvalidAssignee     = errors.New("assignee is not an active team member")
	ErrAlertEmptyNote           = errors.New("note cannot be empty")
	ErrAlertNoteTooLong         = errors.New("note exceeds 2000 character limit")
)

// API limits for batch operations
const (
	// MaxBatchSize is the maximum number of names in a single batch.
	MaxBatchSize = 10_000

	// MaxIDsPerRequest caps how many entries ScreenRequest.IDs may contain.
	// Real entities rarely carry more than a handful of identifiers; the cap
	// is generous enough for compounded passport + tax ID + crypto wallet
	// portfolios while bounding worst-case work per screening call.
	MaxIDsPerRequest = 20
)

// =============================================================================
// GET /v1/alerts/activity - Export case activity audit trail
// =============================================================================
//
// Returns List[AlertActivityResponse]: the case-activity audit trail, cursor-
// paginated by activity row id (?after=&limit=). Filters: from, to (YYYY-MM-DD),
// alert_id, actor, action. Large pulls belong to the async export job.

// =============================================================================
// POST /v1/alerts/bulk/resolve - Bulk resolve alerts
// =============================================================================

// BulkResolveRequest resolves multiple alerts with the same status and reason.
type BulkResolveRequest struct {
	// Alert IDs to resolve
	AlertIDs []int64 `json:"alert_ids"`

	// Target status: "closed_false_positive" or "closed_true_match"
	Status string `json:"status"`

	// Structured reason code (required)
	Reason string `json:"reason"`

	// Disposition for true matches (required when status=closed_true_match)
	Disposition string `json:"disposition,omitempty"`

	// GL citation (required when disposition=permitted_gl)
	GLCitation string `json:"gl_citation,omitempty"`

	// Optional note applied to all alerts
	Note string `json:"note,omitempty"`
}

// BulkResolveResponse reports per-alert success or failure.
type BulkResolveResponse struct {
	Resolved int                 `json:"resolved"`
	Failed   int                 `json:"failed"`
	Results  []BulkResolveResult `json:"results"`
}

// BulkResolveResult is the outcome for a single alert in a bulk resolve.
type BulkResolveResult struct {
	AlertID int64  `json:"alert_id"`
	Success bool   `json:"success"`
	Error   string `json:"error,omitempty"`
}

// MaxBulkResolveSize limits how many alerts can be resolved in a single bulk request.
const MaxBulkResolveSize = 200

// =============================================================================
// POST /v1/alerts/bulk/assign - Bulk assign alerts
// =============================================================================

// BulkAssignRequest assigns multiple alerts to the same analyst.
type BulkAssignRequest struct {
	// Alert IDs to assign
	AlertIDs []int64 `json:"alert_ids"`

	// Email of the analyst to assign to (required)
	AssignTo string `json:"assign_to"`
}

// BulkAssignResponse reports per-alert success or failure.
type BulkAssignResponse struct {
	Assigned int                `json:"assigned"`
	Failed   int                `json:"failed"`
	Results  []BulkAssignResult `json:"results"`
}

// BulkAssignResult is the outcome for a single alert in a bulk assign.
type BulkAssignResult struct {
	AlertID int64  `json:"alert_id"`
	Success bool   `json:"success"`
	Error   string `json:"error,omitempty"`
}

// MaxBulkAssignSize limits how many alerts can be assigned in a single bulk request.
const MaxBulkAssignSize = 200

// =============================================================================
// GET /v1/alerts/stats - Alert queue statistics
// =============================================================================

// AlertStatsResponse provides a breakdown of the alert queue for management
// dashboards and compliance reporting. Answers "how many alerts by status
// and score tier?" without paginating through the full list.
type AlertStatsResponse struct {
	// Total alerts across all statuses
	Total int `json:"total"`

	// Counts by status
	ByStatus map[string]int `json:"by_status"`

	// Counts by score band for open (non-closed) alerts
	ByScoreBand map[string]int `json:"by_score_band"`

	// SLA compliance for open alerts
	Overdue    int `json:"overdue"`
	WithinSLA  int `json:"within_sla"`
	NoDeadline int `json:"no_deadline"`
}

// ActivityStatsResponse is the count companion to GET /v1/alerts/activity.
// Answers "how many case-activity records match this filter/period?" without
// paging the trail. Honors the same alert_id/actor/action/from/to filters as
// the list endpoint.
type ActivityStatsResponse struct {
	// Total activity records matching the (optional) filters.
	Total int `json:"total"`

	// ByAction counts records by action (e.g. assign, escalate, dismiss).
	ByAction map[string]int `json:"by_action"`
}

// =============================================================================
// GET /v1/alerts/resolutions/stats - Observed false-positive rate from analyst
// resolutions
// =============================================================================
//
// The production false-positive feedback loop (#92): every alert resolution
// carries a structured reason code, and this endpoint aggregates those
// dispositions into an observed FP rate by score band — the measured
// counterpart to the fixture-derived FP benchmark. Use it to answer "of the
// alerts we raise at each score band, what share do analysts dismiss as
// false positives?" — the driving input of the threshold tuning procedure
// (docs/testing/tuning.md).
//
// Counting model: resolution events from the immutable case-activity trail
// (action=status_change into a closed_* status), joined to the alert for its
// score. A reopened-and-reclosed alert contributes each closure — the rate
// measures analyst decisions, not current alert states. Only scores >= the
// tenant's alert threshold ever create alerts, so bands below the threshold
// in effect at alert time are structurally empty.
//
// Optional query parameters: from and to (YYYY-MM-DD, inclusive) bound the
// resolution date; omitting both aggregates all history.

// ResolutionBandStats is one score band's resolution outcomes.
type ResolutionBandStats struct {
	// Resolved is total resolution events in this band.
	Resolved int `json:"resolved"`

	// FalsePositives is resolutions into closed_false_positive.
	FalsePositives int `json:"false_positives"`

	// TrueMatches is resolutions into closed_true_match.
	TrueMatches int `json:"true_matches"`

	// FPRate is FalsePositives / Resolved (0 when the band is empty).
	FPRate float64 `json:"fp_rate"`
}

// ResolutionStatsResponse reports resolution-derived screening quality
// metrics. "Resolution" here is the analyst decision recorded by POST
// /v1/alerts/{id}/resolve — distinct from ScreenMatch.DismissalSignals,
// which are engine-computed score-reduction signals on a single match.
type ResolutionStatsResponse struct {
	// Total resolution events in the window.
	Total int `json:"total"`

	// FalsePositives and TrueMatches are the window-wide outcome split.
	FalsePositives int `json:"false_positives"`
	TrueMatches    int `json:"true_matches"`

	// ObservedFPRate is FalsePositives / Total (0 when Total is 0) — the
	// production-measured false-positive rate.
	ObservedFPRate float64 `json:"observed_fp_rate"`

	// ByScoreBand splits outcomes by the alert's match score band
	// ("95-100", "90-94", "80-89", "below-80" — same bands as
	// GET /v1/alerts/stats).
	ByScoreBand map[string]ResolutionBandStats `json:"by_score_band"`

	// ByReason counts resolutions by their structured reason code
	// (Reason* constants).
	ByReason map[string]int `json:"by_reason"`

	// From/To echo the applied window bounds (YYYY-MM-DD), empty when
	// unbounded.
	From string `json:"from,omitempty"`
	To   string `json:"to,omitempty"`
}

// =============================================================================
// GET /v1/alerts/{id}/ofac-report - Generate OFAC-ready blocking/reject report
// =============================================================================

// OFACReportResponse assembles everything OFAC needs for a blocking or reject
// report into a single response. Only available on alerts resolved as
// closed_true_match with a disposition of "blocked" or "rejected".
type OFACReportResponse struct {
	// Alert that triggered this report
	AlertID int64 `json:"alert_id"`

	// Report type derived from disposition: "blocking" or "reject"
	ReportType string `json:"report_type"`

	// When this report was generated (RFC 3339)
	GeneratedAt string `json:"generated_at"`

	// The name that was screened
	ScreenedName string `json:"screened_name"`

	// SDN entry that matched
	SDNMatch OFACReportSDNMatch `json:"sdn_match"`

	// Screening audit data
	Screening OFACReportScreening `json:"screening"`

	// How the alert was resolved
	Resolution OFACReportResolution `json:"resolution"`

	// Fields your institution must complete before filing
	InstitutionFields OFACReportInstitutionFields `json:"institution_fields"`
}

// OFACReportSDNMatch contains the matched SDN entry details for the report.
type OFACReportSDNMatch struct {
	UID          string        `json:"uid"`
	FirstName    string        `json:"first_name,omitempty"`
	LastName     string        `json:"last_name,omitempty"`
	Programs     []string      `json:"programs,omitempty"`
	Score        float64       `json:"score"`
	MatchSources []MatchSource `json:"match_sources,omitempty"`
}

// OFACReportScreening contains audit trail data from the screening.
type OFACReportScreening struct {
	ScreenedAt      string `json:"screened_at"`
	OFACPublishID   string `json:"ofac_publish_id,omitempty"`
	OFACPublishDate string `json:"ofac_publish_date,omitempty"`
	TraceID         string `json:"trace_id"`
}

// OFACReportResolution contains the alert resolution details.
type OFACReportResolution struct {
	Status      string `json:"status"`
	Disposition string `json:"disposition"`
	Reason      string `json:"reason"`
	ResolvedBy  string `json:"resolved_by"`
	ResolvedAt  string `json:"resolved_at"`
	GLCitation  string `json:"gl_citation,omitempty"`
	Note        string `json:"note,omitempty"`
}

// OFACReportInstitutionFields are fields Noble Sight cannot provide.
// Your institution must complete these before filing with OFAC.
type OFACReportInstitutionFields struct {
	TransactionDescription *string `json:"transaction_description"`
	TransactionValue       *string `json:"transaction_value"`
	Instructions           string  `json:"instructions"`
}

// ErrAlertNotTrueMatch is returned when requesting an OFAC report for an alert
// that is not resolved as a true match with a reportable disposition.
var ErrAlertNotTrueMatch = errors.New("OFAC report requires a true match alert with blocked or rejected disposition")

// Error code for OFAC report precondition failures.
const CodeAlertNotReportable = "alert_not_reportable"

// =============================================================================
// GET /v1/account - Account overview for the authenticated API key
// =============================================================================

// AccountResponse returns the account overview for the authenticated API key.
// Combines tier, usage, billing status, and rate limits in a single call.
type AccountResponse struct {
	// Client identifier
	ClientID string `json:"client_id"`

	// Current API key prefix (for identification)
	KeyPrefix string `json:"key_prefix"`

	// Current tier: free, standard, premium, enterprise
	Tier string `json:"tier"`

	// Billing status: "active", "none" (free tier), "past_due", "canceled"
	BillingStatus string `json:"billing_status"`

	// Usage in the current rate limit window
	Usage AccountUsage `json:"usage"`

	// Features available on this tier
	Features AccountFeatures `json:"features"`

	// Capability scopes granted to this key. Every key has "api" (REST access).
	// Additional scopes like "mcp" gate access to specific surfaces — the MCP
	// server sends X-Noble-Client: mcp on every request, which the API rejects
	// with 403 (code: scope_required) when "mcp" is not present here. Agents
	// can read this to discover whether MCP setup will work before attempting.
	Scopes []string `json:"scopes"`
}

// AccountUsage contains current usage stats.
type AccountUsage struct {
	// Requests used in the current window
	Used int `json:"used"`

	// Total request limit for the current window
	Limit int `json:"limit"`

	// Requests remaining
	Remaining int `json:"remaining"`

	// Rate limit window: "day" (free) or "month" (paid)
	Window string `json:"window"`
}

// AccountFeatures describes what the current tier unlocks.
type AccountFeatures struct {
	Screening         bool `json:"screening"`
	DeepScreen        bool `json:"deep_screen"`
	BatchScreening    bool `json:"batch_screening"`
	PortfolioMonitor  bool `json:"portfolio_monitoring"`
	ComplianceExports bool `json:"compliance_exports"`
	AlertManagement   bool `json:"alert_management"`
	MaxBatchSize      int  `json:"max_batch_size"`

	// MCP reports whether this key can use the Model Context Protocol server —
	// the 'mcp' scope. Granted on every tier.
	MCP bool `json:"mcp"`

	// ConfidentialPortfolio reports whether this key holds the Confidential
	// Portfolio (BYOK) add-on: portfolio PII stored encrypted at rest under a
	// customer-controlled KMS key. Enterprise add-on; manage the key via
	// /v1/portfolio/key.
	ConfidentialPortfolio bool `json:"confidential_portfolio"`

	// AlertThreshold is the minimum match score that triggers alert creation.
	// Screenings with a highest match score below this value do not generate alerts.
	// Default: 80. Configured per tenant; valid range 50-95 (bounds are
	// database-enforced so no configuration can silently suppress
	// strong-match alerts).
	AlertThreshold int `json:"alert_threshold"`
}

// =============================================================================
// /v1/portfolio/key - Confidential Portfolio (BYOK) key management
//
//	GET    /v1/portfolio/key  → key status/metadata (never key material)
//	POST   /v1/portfolio/key  → provision a per-customer KMS-wrapped key
//	DELETE /v1/portfolio/key?confirm=<client_id>  → revoke + crypto-shred
//
// Requires the Confidential Portfolio add-on (RequireConfidentialPortfolio).
// DELETE is irreversible: it destroys the KMS key, permanently rendering all of
// the customer's encrypted portfolio PII unreadable. It demands the client_id as
// a confirmation query parameter to prevent accidental destruction.
//
// Capacity: encrypted portfolios are supported up to ~1,000,000 entities.
// Ciphertext cannot be probed through the trigram index, so portfolio
// monitoring decrypts the encrypted subset in memory on each list delta —
// a cost that grows linearly with encrypted rows. Larger encrypted portfolios
// require an engineering conversation (searchable-encryption roadmap) before
// onboarding; plaintext portfolios have no such ceiling.
// =============================================================================

// PortfolioKeyStatusResponse reports the state of a client's Confidential
// Portfolio encryption key. It never includes key material (DEK or wrapped DEK).
type PortfolioKeyStatusResponse struct {
	// Status is "active" (key in use), "revoked" (crypto-shredded), or "none"
	// (never provisioned).
	Status    string `json:"status"`
	CreatedAt string `json:"created_at,omitempty"`
	RevokedAt string `json:"revoked_at,omitempty"`
}

// =============================================================================
// POST /v1/account/checkout - Create a Stripe Checkout session for tier upgrade
// =============================================================================

// CheckoutRequest is the input for creating a billing checkout session.
type CheckoutRequest struct {
	// Target tier to upgrade to: "standard", "premium"
	// Enterprise requires contacting sales.
	Tier string `json:"tier"`

	// AddOn, when set, purchases an add-on instead of a tier upgrade. The only
	// supported value is "confidential_portfolio" (the BYOK Enterprise add-on),
	// which requires the account already be on the Enterprise tier.
	AddOn string `json:"add_on,omitempty"`
}

// AddOnConfidentialPortfolio is the CheckoutRequest.AddOn value that purchases
// the Confidential Portfolio (BYOK) Enterprise add-on.
const AddOnConfidentialPortfolio = "confidential_portfolio"

// CheckoutResponse returns the Stripe Checkout URL for payment.
type CheckoutResponse struct {
	// URL to redirect the user to for payment
	CheckoutURL string `json:"checkout_url"`
}

// Error codes for billing operations.
const (
	// CodeDeepScreenUnavailable: deep_screen was requested (and the tier
	// allows it) but this deployment has no AI backend configured. 503 —
	// the request is NOT silently downgraded to standard screening; retry
	// without deep_screen or contact support. (Runtime AI failures degrade
	// with an explicit deep_screen_fallback_reason instead.)
	CodeDeepScreenUnavailable = "deep_screen_unavailable"

	CodeBillingNotEnabled = "billing_not_enabled"
	CodeAlreadyOnTier     = "already_on_tier"
	CodeInvalidTier       = "invalid_upgrade_tier"
)

// =============================================================================
// POST /v1/watchlist - Add an internal watchlist entry
// =============================================================================

// WatchlistAddRequest is the input for adding a watchlist entry.
type WatchlistAddRequest struct {
	// Full name to add to the watchlist (required)
	FullName string `json:"full_name"`

	// List name for categorization (e.g., "SAR Subjects", "Exited Customers").
	// Defaults to "default".
	ListName string `json:"list_name,omitempty"`

	// Why this entity is on the watchlist (recommended for audit trail)
	Reason string `json:"reason,omitempty"`

	// Government identifier (optional)
	IDNumber string `json:"id_number,omitempty"`

	// ID type (e.g., "Passport", "SSN") (optional)
	IDType string `json:"id_type,omitempty"`

	// Country code ISO 3166-1 alpha-2 (optional)
	Country string `json:"country,omitempty"`

	// Entity type: "individual" or "entity" (default: "individual")
	EntityType string `json:"entity_type,omitempty"`

	// Expiration date in RFC 3339 format (optional). Entry stops matching after this date.
	ExpiresAt string `json:"expires_at,omitempty"`
}

// WatchlistResponse is a single watchlist entry.
type WatchlistResponse struct {
	// Entry ID
	ID int64 `json:"id"`

	// Full name on the watchlist
	FullName string `json:"full_name"`

	// List category
	ListName string `json:"list_name"`

	// Why this entity was added
	Reason string `json:"reason,omitempty"`

	// Government identifier
	IDNumber string `json:"id_number,omitempty"`

	// ID type
	IDType string `json:"id_type,omitempty"`

	// Country code
	Country string `json:"country,omitempty"`

	// Entity type
	EntityType string `json:"entity_type"`

	// Who added this entry
	AddedBy string `json:"added_by"`

	// When this entry was created (RFC 3339)
	CreatedAt string `json:"created_at"`

	// When this entry expires (RFC 3339), if set
	ExpiresAt string `json:"expires_at,omitempty"`

	// Entry status: "active" or "removed"
	Status string `json:"status"`
}

// GET /v1/watchlist returns List[WatchlistResponse], cursor-paginated by entry
// id (?after=&limit=), ordered id ascending — see the "Pagination" contract.
// Optional filters: list_name, status (defaults to "active").

// MaxWatchlistNameLength is the maximum length for a watchlist entry name.
const MaxWatchlistNameLength = 500

// MaxWatchlistImportSize is the maximum entries in a single bulk import.
const MaxWatchlistImportSize = 10_000

// =============================================================================
// Webhooks — POST /v1/webhooks, GET /v1/webhooks, DELETE /v1/webhooks?id=N
// =============================================================================

// Webhook limits.
const (
	// MaxWebhooksPerTenant is the maximum number of active webhook registrations per tenant.
	MaxWebhooksPerTenant = 5

	// MinWebhookSecretLength is the minimum length for the shared HMAC secret.
	MinWebhookSecretLength = 32

	// MaxWebhookURLLength is the maximum allowed URL length.
	MaxWebhookURLLength = 2048

	// MaxWebhookDeliveryPasses is the number of delivery passes before an
	// alert event is marked failed. Passes are scheduled on a persisted
	// backoff spanning roughly 10 hours and survive server restarts; each
	// pass makes one POST per registered endpoint that has not yet accepted
	// the event. Events carry a stable evt_* id (payload "id" and
	// X-Noble-Event-ID header) so consumers can deduplicate at-least-once
	// delivery. A delivery failure never suppresses the alert itself —
	// GET /v1/alerts remains the system of record.
	MaxWebhookDeliveryPasses = 8
)

// WebhookCreateRequest registers a URL to receive alert notifications.
type WebhookCreateRequest struct {
	// HTTPS URL to receive POST notifications (required)
	URL string `json:"url"`

	// Shared secret for HMAC-SHA256 signature verification (required, min 32 chars).
	// Noble signs every outbound payload with this secret. The customer verifies
	// the X-Noble-Signature header to confirm authenticity.
	Secret string `json:"secret"`
}

// WebhookResponse is a registered webhook. The URL is masked after creation.
type WebhookResponse struct {
	ID        int64  `json:"id"`
	URL       string `json:"url"`
	Status    string `json:"status"`
	CreatedAt string `json:"created_at"`
}

// GET /v1/webhooks returns List[WebhookResponse]. Unlike the other collection
// endpoints it is NOT paginated — a tenant holds at most MaxWebhooksPerTenant
// registrations, so the endpoint returns them all in data and ignores any
// ?after=/?limit= cursor params.

// WebhookStatsResponse is the count companion to GET /v1/webhooks. Because
// registrations are capped per tenant, the headline value is how much of that
// budget remains — "how many more webhooks can I register?"
type WebhookStatsResponse struct {
	// Total registered webhooks for the tenant.
	Total int `json:"total"`

	// ByStatus counts registrations by status (e.g. active, disabled).
	ByStatus map[string]int `json:"by_status"`

	// Limit is the per-tenant cap (MaxWebhooksPerTenant).
	Limit int `json:"limit"`

	// Remaining registrations available before hitting Limit (never negative).
	Remaining int `json:"remaining"`
}

// WebhookEvent is the payload POSTed to customer webhook URLs.
// Customers verify authenticity via the X-Noble-Signature header:
//
//	signature = HMAC-SHA256(secret, request_body)
//	header:     X-Noble-Signature: sha256=<hex>
type WebhookEvent struct {
	// Unique event ID (evt_ prefix)
	ID string `json:"id"`

	// Event type: "alert.created"
	Type string `json:"type"`

	// When this event was generated (RFC 3339)
	CreatedAt string `json:"created_at"`

	// Event payload (AlertResponse JSON)
	Data json.RawMessage `json:"data"`
}

// =============================================================================
// GET /status - Public system status (no auth required)
// =============================================================================

// StatusResponse reports system health for customer monitoring dashboards.
// No authentication required — compliance teams need to check this independently.
type StatusResponse struct {
	// Overall status: "operational", "degraded", "outage"
	Status string `json:"status"`

	// Human-readable description
	Message string `json:"message"`

	// Component health
	Components StatusComponents `json:"components"`

	// When this status was generated (RFC 3339)
	Timestamp string `json:"timestamp"`
}

// StatusComponents reports health of individual subsystems.
type StatusComponents struct {
	// Database connectivity
	Database ComponentStatus `json:"database"`

	// Screening API availability
	Screening ComponentStatus `json:"screening"`

	// Sanctions list ingestion freshness, from the same computation as
	// GET /v1/lists: "operational" when every deployed list was polled
	// within the freshness window (24h), "degraded" when any list is
	// stale — screening continues against the last imported version;
	// query GET /v1/lists for per-list status. "outage" when freshness
	// cannot be determined (database unreachable). A degraded lists
	// component degrades the overall status.
	Lists ComponentStatus `json:"lists"`
}

// ComponentStatus is the health of a single component.
type ComponentStatus struct {
	// Status: "operational", "degraded", "outage"
	Status string `json:"status"`

	// Response time in milliseconds (where measurable)
	LatencyMs *int64 `json:"latency_ms,omitempty"`
}

// Status constants for system health reporting.
const (
	StatusOperational = "operational"
	StatusDegraded    = "degraded"
	StatusOutage      = "outage"
)

// =============================================================================
// Headers
// =============================================================================
//
// X-API-Key (required for /v1/* endpoints): API key for authentication.
//            Keys use the format: noble_live_ + 32 hex characters.
//
//   Screening API tiers:
//     Free:       100 screenings/day   ($0/mo)
//     Standard:     5,000 screenings/mo  ($199/mo)
//     Premium: 25,000 screenings/mo ($499/mo)
//     Enterprise:   100,000+ screenings/mo (custom pricing)
//
//   Portfolio Monitoring: per-entity add-on, available on any paid tier.
//
// X-Trace-ID (optional): Client-provided trace ID for audit logging.
//                        Will be included in logs and database query comments.
//
// X-Noble-Client (optional): Identifies the official Noble client making the
//                            request. Currently recognized values:
//                              mcp  — the `noble mcp` Model Context Protocol
//                                     server. When this header is present, the
//                                     API key must carry the "mcp" scope or
//                                     the request is rejected 403
//                                     (code: scope_required).
//                            Third-party clients should not set this header.
//                            Setting it without the matching scope is harmless
//                            but produces a 403 instead of normal access.
//
// Response headers on every request:
//   X-Request-Id:          Server-generated request ID (req_ + 16 hex chars).
//
// Response headers on authenticated requests:
//   X-RateLimit-Limit:     Request limit for this key (daily or monthly).
//   X-RateLimit-Remaining: Requests remaining in current window.
//   X-RateLimit-Window:    "day" (Free) or "month" (paid tiers).
//   X-RateLimit-Reset:     Unix timestamp when the rate limit window resets.
//
// Response headers on 429 (rate limit exceeded):
//   Retry-After:           Seconds until the rate limit window resets.
//
// Response headers on requests authenticated by a trial/comp key:
//   X-Trial-Ends-At:        RFC 3339 timestamp when the trial expires.
//   X-Trial-Days-Remaining: Whole days until trial expiry (0 on the final day).
//
// Response on 402 (trial expired):
//   Error code: trial_expired. The customer's trial_ends_at has passed and a
//   nightly job has flipped the key's status to "expired". Contact sales to
//   convert or extend.

Changelog

Recent API changes. Subscribe by polling GET /v1/changelog — public, no API key required, returns the same entries as machine-readable JSON.

DateCategoryChange
2026-08-16 added OFAC 50 Percent Rule screening: /v1/screen responses now carry an ownership determination — an entity majority-owned by sanctioned parties surfaces as blocked with its evidence chains and raises its own alert, even when the entity appears on no sanctions list
/v1/screen
Under OFAC's 50 Percent Rule (FAQs 398/401), an entity owned 50% or more in the aggregate by blocked persons is itself blocked even though it is not listed. Screening now answers that question: every live name screening includes an ownership object with a determination — blocked (aggregate sanctioned ownership of 50% or more, with the ownership chains as evidence), review (a relationship to a sanctioned party exists but no source discloses a percentage; requires investigation), clean (known ownership structure, no sanctioned ownership found), or unknown (the name is not in the ownership data; absence of data is reported explicitly, never silently). Determinations are precomputed from sanctions-list ownership assertions corroborated by GLEIF corporate-relationship data, and each chain hop names its source, so the evidence is examiner-traceable; as_of screenings and identifier-only requests omit the field. A blocked determination raises a distinct alert (ownership_alert_id, alert source "ownership") that bypasses the score threshold — the determination is categorical, not a match confidence. Additive — no version change.
2026-08-15 changed The jurisdiction_risk table is reconciled against current OFAC programs: Syria no longer returns a comprehensive-embargo signal following the July 2025 revocation of its sanctions program, and every remaining entry carries its current program citation
/v1/screen
Executive Order 14312 (June 30, 2025) revoked the Syria sanctions program effective July 1, 2025, and OFAC removed the Syrian Sanctions Regulations (31 CFR Part 542) from the Code of Federal Regulations on August 26, 2025 — so a country input of Syria no longer produces an advisory jurisdiction_risk signal. Remaining Syria-related U.S. authorities are list-based designations, which name screening continues to cover. The comprehensive-embargo set is now Cuba (31 CFR Part 515), Iran (31 CFR Part 560), North Korea (31 CFR Part 510), Crimea and Sevastopol (E.O. 13685; 31 CFR Part 589), and the so-called DNR and LNR covered regions (E.O. 14065; 31 CFR Part 587). Kherson and Zaporizhzhia remain excluded because the Secretary of the Treasury has never determined them to be E.O. 14065 covered regions (OFAC FAQ 1006). Response shapes are unchanged — no version change.
2026-08-15 changed UK screening now sources the FCDO UK Sanctions List — the UK government's single source for all UK designations since the OFSI Consolidated List closed in January 2026 — broadening UK coverage to every designation type
/v1/lists
The United Kingdom consolidated its sanctions publications: the OFSI Consolidated List of Asset Freeze Targets closed on 28 January 2026, and the UK Sanctions List published by the Foreign, Commonwealth & Development Office is now the sole authoritative source. UK screening ingests that list directly. Coverage broadens from asset-freeze targets to all UK designations (including travel-ban and other measures) — roughly 6,300 designations versus roughly 5,100 previously. Designation identifiers are unchanged (the same UKSL reference IDs, e.g. RUS0001), so alerts, match sources (uk_designation, uk_alias), and screening history remain continuous. GET /v1/lists now reports the list as "UK Sanctions List (FCDO)" with the publisher's own generation date as publish_date. Response shapes are unchanged — no version change.
2026-08-15 added Canada (SEMA) and Australia (DFAT) consolidated sanctions lists are live — both appear in GET /v1/lists and participate in screening and portfolio monitoring
/v1/lists
Screening coverage now includes Canada's Special Economic Measures Act consolidated list and Australia's DFAT consolidated list. Both lists ingest on the standard polling cadence, appear in GET /v1/lists with their publisher snapshot and freshness status, are selectable via the screening lists parameter (canada, australia), and feed portfolio monitoring like every other source. Additive — no version change.
2026-08-15 added GET /status now reports sanctions-list ingestion freshness as a lists component — monitoring dashboards see degraded the moment any list's ingestion goes stale, without polling /v1/lists per list
/status
The public status endpoint gains components.lists, computed from the same freshness verdict as GET /v1/lists: operational when every deployed list was polled within the documented freshness window (24h), degraded when any list is stale. A degraded lists component degrades the overall status (HTTP 200 with status "degraded") — screening remains available and continues against the last imported lists; GET /v1/lists identifies which list is affected. When freshness cannot be determined the component fails closed rather than reporting health. Additive — no version change.
2026-08-14 fixed Batch screening now fails closed on alert creation: an item whose alert cannot be recorded finishes with status error instead of screened, so a match can never complete silently without its alert
/v1/batch
Previously, if creating the alert for an above-threshold batch match failed, the failure was only logged internally — the item still finished as screened, with the match visible in its results but no alert for analysts to adjudicate and no error status to trigger a retry. Now an alert-creation failure marks the item error; re-submit the name to retry (alert de-duplication absorbs any partial state). A screened item with an above-threshold score always carries its alert_id. Additive — no version change.
2026-08-14 fixed An unrecognized country code no longer reduces match scores — absence of country knowledge is never treated as a country mismatch — and roughly sixty additional country codes (including XK, TL, BT) are now recognized
/v1/screen
Screening accepts an optional ISO 3166-1 alpha-2 country to refine scoring. Previously, a code outside the recognized set — newer or user-assigned assignments such as XK (Kosovo), TL (Timor-Leste), or BT (Bhutan) — could be treated as a mismatch against the listed party's countries and reduce the match score, even when the subject's country genuinely matched the sanctions record. A reduced score can keep a true match below the alert threshold, so this failed in the dangerous direction. Now a country code the engine cannot map contributes nothing: no penalty, no country dismissal signal — identical to omitting the country. The recognized code set also grew by roughly sixty countries, which now participate in country comparison normally. Additive — no version change.
2026-08-14 fixed Portfolio monitoring now screens sanctions-list changes under their stored AI name variations — a monitored name matching a new designation only through a transliteration or spelling variant raises an alert, with recall matching point-in-time screening
/v1/alerts
Previously, point-in-time screening (POST /v1/screen) matched names against AI-generated variations of listed names (transliterations, spelling variants, cultural equivalents), but portfolio monitoring re-screened list updates against enrolled names using only each changed entry's name and aliases — a monitored customer whose name resembles a new designation only through such a variant was caught if screened directly, but a later list update did not raise an alert. Now every OFAC SDN list change is also screened under its stored variations, scored identically to the equivalent direct screen, so both directions catch the same matches. A variation match is fully attributed: the alert's match payload names the matched variant and the reason it was generated, and the persisted monitoring evidence records each variation screened. Additive — no version change.
2026-08-14 fixed Portfolio-monitoring alerts now record every subsequent sanctions-list event on the open alert's timeline, and a designation on a different list raises its own alert instead of merging into the open one
/v1/alerts
Previously, while a monitoring alert was open, further list events for the same person — a re-designation or a modified entry with new identifiers — deduplicated into the existing alert without leaving any record, and a same-named designation arriving from a different sanctions list merged into the open alert the same way, so an analyst could resolve an alert without ever seeing that a second list had also designated the person. Now every absorbed list event is appended to the alert's immutable activity timeline as a list_event entry (actor system:monitoring, with change type, list, publish id, and score in the note), visible in GET /v1/alerts/{id}; and the monitoring dedup key includes the source list, so a designation on a different list creates a distinct alert requiring its own adjudication. Duplicate open alerts for identical repeats of the same (entity, person, list) are still prevented. Additive — no version change.
2026-08-13 fixed deep_screen responses now report expansion coverage: a new deep_screen_coverage object counts variations generated vs screened, and any shortfall sets deep_screen_fallback_reason — partial enhanced recall is never silent
/v1/screen
Previously deep_screen_fallback_reason was set only when the AI variation expansion failed entirely; if individual generated variations could not be screened, the response reported success while delivering less recall than requested. Now every deep_screen response where variation generation succeeded carries deep_screen_coverage with variations_generated and variations_screened, and whenever screened < generated the response also sets deep_screen_fallback_reason to variation_screening_failure. Equal counts positively confirm the full expansion was delivered; a whole-expansion failure still reports llm_failure. Retry a partial-coverage request to re-attempt full recall. Additive — no version change.

Full feed: GET /v1/changelog

Auth

Every request requires an API key. Create one with your client ID, pass it in the X-API-Key header, and you're screening. Keys are prefixed noble_live_ for easy identification in your code.

POST /v1/keys

Invite-only: requires an invite_code issued by Noble Sight. Contact sales@noblesight.io for access. Rate limited to 10 requests per minute per IP.

curl -X POST https://noblesight.io/v1/keys \
  -H "Content-Type: application/json" \
  -d '{"client_id": "acme-bank", "name": "Production Key", "accept_terms": true, "invite_code": "noble_inv_..."}'

Response:

{
  "key": "noble_live_a1b2c3d4e5f6...",
  "key_prefix": "noble_live_a1b2",
  "tier": "free",
  "rate_limit_per_day": 100,
  "terms_version": "1.0"
}

The full key is shown once. Store it securely — it cannot be retrieved again.

accept_terms must be true. This confirms your institution acknowledges that screening results are informational only and do not constitute legal or compliance advice. The terms_version in the response records which version was accepted for your audit trail.

GET /v1/usage

Check how many screenings you've used in the current rate limit window.

curl https://noblesight.io/v1/usage \
  -H "X-API-Key: $NOBLE_API_KEY"

Response:

{
  "tier": "free",
  "used": 42,
  "limit": 100,
  "remaining": 58,
  "window": "day"
}

Tiers

TierPriceScreeningsWindow
free$0100Per day
standard$199/mo5,000Per month
premium$499/mo25,000Per month
enterpriseCustom100,000+Per month

Rate Limit Headers

Every authenticated response includes rate limit headers:

HeaderDescription
X-RateLimit-LimitRequest limit for this key
X-RateLimit-RemainingRequests remaining in current window
X-RateLimit-Windowday (free) or month (paid)
X-RateLimit-ResetUnix timestamp when the window resets
Retry-AfterSeconds until reset (only on 429 responses)
X-Request-IdServer-generated request ID for debugging

Screen

One call screens a name against 18,598 sanctioned entities, 24,526 aliases, and 89,117 AI-generated spelling variations — in under 100 milliseconds. Every match shows exactly how it scored and why, down to the individual data source.

POST /v1/screen

Screen a name against all active sanctions lists. Add date of birth, country, or entity type to reduce false positives — each mismatch applies a transparent score penalty.

curl -X POST https://noblesight.io/v1/screen \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -H "X-Trace-ID: onboarding-customer-42" \
  -d '{
    "name": "Vladimir Putin",
    "date_of_birth": "1952-10-07",
    "country": "RU",
    "entity_type": "individual",
    "id_number": "E12345678",
    "id_type": "Passport",
    "lists": ["ofac", "uk", "eu"]
  }'

Every screening response includes a meta.disclaimer field. This is the full legal disclaimer bounding Noble Sight's liability — present in every response so your audit trail captures it alongside each screening decision. The disclaimer text is defined in the API contract.

When a screening produces matches above the alert threshold, the response includes an alert_id field — the ID of the alert created for this screening. Use it to track the match through the alert lifecycle, receive webhook notifications, or poll for new alerts using cursor-based pagination.

The Model Cascade

The architecture follows the model cascade recommended by Federal Reserve research (FEDS 2025-092): fast fuzzy matching for routine screens, AI escalation for hard cases. Every request passes through two paths.

Default
Character + Sound + AI matching
<100ms
deep_screen: true
Real-time Gemini expansion
+1-3s

Allen & Hatfield (2025) found LLMs reduce false positives by 92% but are 10,000x slower than fuzzy matching. Their recommendation: a model cascade — fast matching for easy cases, AI escalation for hard ones.

Match Sources

Every match includes a match_sources array — the complete evidence trail. When an OFAC examiner asks "why did this name match?", the answer is already in the response.

Source What It Matches Against Example
sdn Primary SDN entry names. Trigram similarity against full name, first name, and last name. "Vladimir Putin" matches "Vladimir Vladimirovich PUTIN"
aka Known aliases from OFAC's AKA list. Same trigram + Soundex matching. "Abu Bakr al-Baghdadi" matches AKA "Ibrahim Awwad Ibrahim al-Badri"
ai_variation 89,117 Gemini-generated spelling, transliteration, and cultural variations. Pre-computed at import time with audit reasons. "Qasem Soleimani" matches "Qassem Suleimani" (reason: "Common Farsi-to-English transliteration")
deep_screen Real-time Gemini expansion of the search input. Only when deep_screen: true. "Димитрий Медведев" expands to "Dmitry Medvedev", "Dmitri Medvedev"
ofac_id Exact match against OFAC ID numbers (passports, tax IDs, cedulas). Normalized: case-insensitive, strips spaces/dashes/dots. Score: 100. Passport "E12345678" matches OFAC id_list entry for a sanctioned individual
uk_id Exact match against UK sanctions identification numbers. Same normalization as OFAC. Score: 100. National ID matches UK identification table entry
internal_watchlist Trigram + Soundex match against your tenant's internal watchlist entries (SAR subjects, exited customers, etc.). Same scoring formula as sanctions matching. "John Doe" matches your watchlist entry "Jon Doe" (list: "Exited Customers")

Each source entry includes its own trigram_score and soundex_match flag. The entity's final score uses the best-scoring source.

The Scoring Formula

Character similarity and phonetic agreement combine into one number:

score = MIN(1.0, trigram_score + soundex_boost) × 100

The trigram score measures character-level similarity (0.0–1.0) across the full name, first name, and last name fields. The engine takes the highest. Minimum threshold: 0.15.

The Soundex boost rewards phonetic agreement. Last name match: +0.30. Both first and last: +0.15 additional. "Jon Smyth" scores up to 0.45 higher against "John Smith" than character matching alone.

The score is capped at 100. The entity's final score is the best across all match sources — SDN, AKA, and AI variation.

Soundex handles reversed name order — "Maduro Nicolas" matches "Nicolas MADURO MOROS." AI variations do not receive a Soundex boost because they are already expanded forms.

What the Score Means

Range Meaning Typical Action
90–100 Strong match — near-exact name or phonetic equivalent Escalate for review
70–89 Moderate match — likely a transliteration or variant spelling Review with context
50–69 Weak match — partial similarity only Low priority review

Reducing False Positives

A missed sanction is a federal violation. Too many false positives cause alert fatigue — analysts stop looking carefully, which leads to the same outcome. The score has to be right.

When you provide entity_type, date_of_birth, or country, Noble Sight cross-references them against OFAC data for each match. Mismatches reduce the score using multiplicative penalties. The match is never hidden — the original score and every penalty reason are always in the response.

Entity Type

ConditionMultiplierA 96.7 becomes...
Entity type matches (or not provided)1.0 — no penalty96.7
Entity type mismatch (e.g., individual vs. vessel)0.548.4

Date of Birth

DOB DifferenceMultiplierA 96.7 becomes...
Exact match (within 30 days)1.0 — no penalty96.7
Near miss (within 1 year)0.877.4
Mismatch (within 5 years)0.658.0
Far mismatch (over 5 years)0.438.7

Country

ConditionMultiplierA 96.7 becomes...
Country matches OFAC nationality1.0 — no penalty96.7
Country does not match0.767.7

Penalties are multiplicative and applied in order: entity type, then date of birth, then country. A name scoring 96.7 with matching entity type, a 15-year DOB mismatch, and wrong country:

96.7 × 1.0 × 0.4 × 0.7 = 27.1

The dismissal_reasons array shows exactly which penalties were applied and why.

Entities without OFAC attribute data are never penalized. Absence of data is not evidence of mismatch.

ID Number Matching

When you provide id_number, Noble Sight performs an exact match against sanctions ID tables across all supported lists — passports, tax IDs, cedulas, and other government identifiers. This catches the most dangerous evasion pattern: a sanctioned individual who changes their name but reuses an identity document.

ID numbers are normalized before matching: spaces, dashes, dots, and slashes are stripped, and the comparison is case-insensitive. An ID match always scores 100 — a deterministic identifier hit is the strongest possible signal.

If the same entity is found by both name and ID, the results are merged: the score stays at 100, and both source types (ofac_sdn + ofac_id) appear in match_sources. No duplicates.

Optionally provide id_type (e.g., "Passport", "National ID") to narrow the search to a specific document type.

List Selection

By default, every screening runs against all live sanctions lists: OFAC, UK, EU, UN, France, Belgium, and Netherlands. Use the lists parameter to restrict screening to specific lists — a US broker-dealer might only need OFAC, while an EU bank might screen against OFAC + EU + member states.

Valid values: ofac, uk, eu, france, belgium, netherlands, un. The response always includes a lists_screened field showing which lists were actually queried — populated even when you don't specify a filter, and never listing coverage that isn't loaded.

Internal watchlist screening always runs regardless of the lists filter — it's your tenant's own data, not a government list.

Deep Screen

Deep screen adds real-time Gemini AI expansion. Instead of matching only the name you provide, it generates cultural, phonetic, and transliteration variations of your input — then screens each one. Use it for onboarding, investigations, or any situation where you need the highest possible recall.

curl -X POST https://noblesight.io/v1/screen \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{"name": "Димитрий Медведев", "deep_screen": true}'
Aspect Default Screen Deep Screen
Latency <100ms +1-3 seconds (Gemini API call)
Matching Input name vs. SDN + AKA + stored AI variations Input name + AI-expanded variations vs. SDN + AKA + stored AI variations
Use case Transaction screening, high-volume daily checks Customer onboarding, due diligence, investigations
Tier Free and above Standard and above

Batch

Screen up to 10,000 names in a single request. Submit a batch, poll for status, and retrieve results when complete. Standard tier and above.

POST /v1/batch

Submit a batch of up to 10,000 names. Each name is screened independently against all active sanctions lists with the same matching engine as single screens.

curl -X POST https://noblesight.io/v1/batch \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "names": [
      {"full_name": "Vladimir Putin", "date_of_birth": "1952-10-07", "country": "RU"},
      {"full_name": "Ali Khamenei", "country": "IR", "id_number": "A12345678", "id_type": "Passport"},
      {"full_name": "John Smith"}
    ]
  }'

Response:

{
  "id": 1234,
  "status": "pending",
  "total_names": 3,
  "processed_names": 0,
  "match_count": 0
}

GET /v1/batch/status

Check batch progress. When status reaches completed, the response includes per-name results with match counts and highest scores.

curl "https://noblesight.io/v1/batch/status?id=1234" \
  -H "X-API-Key: $NOBLE_API_KEY"

Response (completed):

{
  "id": 1234,
  "status": "completed",
  "total_names": 3,
  "processed_names": 3,
  "match_count": 2,
  "items": [
    {"full_name": "Vladimir Putin", "status": "screened", "match_count": 1, "highest_score": 97.5, "alert_id": 42},
    {"full_name": "Ali Khamenei", "status": "screened", "match_count": 1, "highest_score": 94.2, "alert_id": 43},
    {"full_name": "John Smith", "status": "screened", "match_count": 0, "highest_score": 0}
  ]
}

Batch statuses: pending, processing, completed, failed.

Alerts

Screening produces matches. Alerts turn matches into a compliance workflow — who reviewed it, what they decided, and why. Every status change, note, and resolution is recorded immutably. This is the audit trail an OFAC examiner expects to see.

Lifecycle

Alerts follow a strict state machine. Each transition is recorded in an immutable case activity log.

Status Meaning Can Transition To
new Alert created, awaiting triage assigned, under_review, escalated, closed_false_positive, closed_true_match
assigned Assigned to an analyst with SLA deadline under_review, escalated
under_review Active investigation escalated, closed_false_positive, closed_true_match
escalated Requires senior compliance review closed_false_positive, closed_true_match
closed_false_positive Dismissed — not a true match under_review (reopen)
closed_true_match Confirmed — sanctions match verified under_review (reopen)

GET /v1/alerts

List alerts for your organization. Filter by status, source, score range, assignee, or date range to focus on what needs attention.

curl "https://noblesight.io/v1/alerts?status=new&min_score=80&limit=25" \
  -H "X-API-Key: $NOBLE_API_KEY"

Query Parameters

ParamTypeDescription
statusstringFilter by status (e.g., new, under_review)
sourcestringFilter by source (api, console, monitor)
min_scorenumberMinimum score threshold (0-100)
max_scorenumberMaximum score threshold (0-100)
assigned_tostringFilter by assigned analyst email
fromstringStart date (YYYY-MM-DD)
tostringEnd date (YYYY-MM-DD)
afterintegerCursor: return alerts with ID greater than this value. Pass the id of the last alert in the previous page's data. Omit for the first page.
limitintegerPage size (default: 50, max: 100). Larger values are clamped.

Cursor pagination only — keyset by id, ordered ascending. The response is the uniform { "data": [...], "has_more": bool } envelope: when has_more is true, request the next page with ?after=<id of the last item in data>. There is no page/offset and no total (use GET /v1/alerts/stats for queue counts). For compliance teams: because every alert has a monotonically increasing ID and pages never overlap or skip, your system can prove to an examiner that it processed every alert in sequence.

GET /v1/alerts/{id}

Retrieve full alert detail: the match payload, SDN entry data, complete case activity timeline, and final disposition.

curl "https://noblesight.io/v1/alerts/42" \
  -H "X-API-Key: $NOBLE_API_KEY"

POST /v1/alerts/{id}/assign

Assign an alert to an analyst for investigation. SLA deadlines are set automatically: 24 hours for scores 90 and above, 72 hours for all others.

curl -X POST "https://noblesight.io/v1/alerts/42/assign" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{"assign_to": "analyst@yourcompany.com"}'

POST /v1/alerts/{id}/notes

Add an investigation note without changing the alert's status. Notes are immutable and become part of the audit trail.

curl -X POST "https://noblesight.io/v1/alerts/42/notes" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{"note": "Checked DOB against passport — different person."}'

POST /v1/alerts/{id}/escalate

Escalate an alert for senior compliance review. Optionally include a note explaining why.

curl -X POST "https://noblesight.io/v1/alerts/42/escalate" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{"note": "Name matches known alias pattern — needs MLRO sign-off."}'

POST /v1/alerts/{id}/resolve

Close an alert with a structured reason code. True match closures require a disposition — the action your institution took. The four-eyes rule is enforced for alerts scoring 90 or above.

curl -X POST "https://noblesight.io/v1/alerts/42/resolve" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "status": "closed_true_match",
    "reason": "true_match",
    "disposition": "blocked",
    "note": "Transaction blocked per OFAC regulations."
  }'

POST /v1/alerts/bulk/resolve

Resolve up to 200 alerts in a single request. Each alert gets its own result in the response — failures on individual alerts don't block the rest.

curl -X POST "https://noblesight.io/v1/alerts/bulk/resolve" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "alert_ids": [42, 43],
    "status": "closed_false_positive",
    "reason": "false_positive_name_only"
  }'

GET /v1/alerts/activity

Export the case activity audit trail across all alerts. Built for OFAC examination preparation — every status change, note, assignment, and resolution with full attribution and timestamps.

curl "https://noblesight.io/v1/alerts/activity?from=2026-01-01&to=2026-03-01&limit=100" \
  -H "X-API-Key: $NOBLE_API_KEY"

Query Parameters

ParamTypeDescription
fromstringStart date (YYYY-MM-DD)
tostringEnd date (YYYY-MM-DD)
alert_idintegerFilter to a specific alert
actorstringFilter by actor email
actionstringFilter by action type
afterintegerCursor: return activity rows with id greater than this (pass the last item's id from the previous page)
limitintegerResults per page (default: 50, max: 100)

Cursor-paginated by activity id ascending; returns the { "data": [...], "has_more": bool } envelope. A full audit pull uses the async export job.

After a True Match

Screening finds the match. The alert workflow documents your decision. But what happens next? When your institution confirms a sanctions match, OFAC requires specific actions within specific deadlines. The disposition you choose determines your reporting obligation under 31 CFR Part 501.

Disposition OFAC Obligation Deadline Filed With
blocked File a blocking report. Property or transactions must be frozen and reported. 10 business days OFAC Compliance Division
rejected File a reject report. Wires, ACH, and trade transactions that were stopped. 10 business days OFAC Compliance Division
permitted_gl No filing required, but maintain documentation of which General License applies and why. Cite the specific GL in the gl_citation field. N/A — document immediately Internal records
no_action_required No filing required. Match confirmed but no reportable transaction or property involved. N/A — document immediately Internal records

Noble Sight stores the disposition, reason code, analyst attribution, and timestamp for every resolution. This is the evidence your compliance team will present during an examination.

GET /v1/alerts/{id}/ofac-report

For blocked and rejected dispositions, this endpoint assembles an OFAC-ready report — the screened name, SDN match details, sanctions programs, OFAC list version, and resolution audit trail in a single response. Only available on alerts closed as closed_true_match.

curl "https://noblesight.io/v1/alerts/42/ofac-report" \
  -H "X-API-Key: $NOBLE_API_KEY"

Response:

{
  "alert_id": 42,
  "report_type": "blocking",
  "generated_at": "2026-03-10T14:30:00Z",
  "screened_name": "Vladimir Putin",
  "sdn_match": {
    "uid": "36095",
    "first_name": "Vladimir",
    "last_name": "PUTIN",
    "programs": ["RUSSIA-EO14024"],
    "score": 97.5,
    "match_sources": [{"source": "ofac_sdn", "trigram_score": 100, "soundex_match": true}]
  },
  "screening": {
    "screened_at": "2026-03-09T10:15:00Z",
    "ofac_publish_id": "1234",
    "ofac_publish_date": "2026-03-08",
    "trace_id": "onboarding-customer-42"
  },
  "resolution": {
    "status": "closed_true_match",
    "disposition": "blocked",
    "reason": "true_match",
    "resolved_by": "analyst@yourcompany.com",
    "resolved_at": "2026-03-10T14:00:00Z",
    "note": "Transaction blocked per OFAC regulations."
  },
  "institution_fields": {
    "transaction_description": null,
    "transaction_value": null,
    "instructions": "Complete transaction_description and transaction_value before filing with OFAC. Blocking reports must be filed within 10 business days (31 CFR 501.603)."
  }
}

Returns 409 Conflict if the alert is not resolved as a true match with a blocked or rejected disposition. The institution_fields section contains null values your team must fill in — Noble Sight provides the sanctions match evidence, your institution provides the transaction context.

What OFAC Needs vs. What the Report Provides

OFAC Requires Report Field Source
Name of blocked/rejected party screened_name Noble Sight
SDN list entry matched sdn_match.uid, sdn_match.first_name, sdn_match.last_name Noble Sight
Sanctions program sdn_match.programs Noble Sight
Date of action screening.screened_at Noble Sight
OFAC list version screening.ofac_publish_id, screening.ofac_publish_date Noble Sight
Description of blocked property or rejected transaction institution_fields.transaction_description Your institution
Value of blocked property or rejected transaction institution_fields.transaction_value Your institution

How to Contact OFAC

Different situations require different OFAC channels. Use the right one.

Situation OFAC Channel Contact
"Is this a real match?" — questions about potential hits, list interpretation, or screening methodology Compliance Hotline ofac.treasury.gov/ofac-compliance-hotline
Filing a blocking or reject report OFAC Compliance Division ofac.treasury.gov/contact-ofac
Requesting a license to proceed with a blocked transaction Licensing Hotline ofac.treasury.gov/ofac-license-application-page
Reporting a potential violation your institution discovered Voluntary Self-Disclosure Portal ofac.treasury.gov/disclosure
Issues downloading or accessing OFAC sanctions lists Technical Support 1-800-540-6322 or O_F_A_C@treasury.gov

Voluntary Self-Disclosure

If your institution discovers it processed a transaction involving a sanctioned party — even if the screening system flagged it correctly but the alert was mishandled — OFAC's Voluntary Self-Disclosure program provides significant penalty mitigation. Under OFAC's Enforcement Guidelines, voluntary self-disclosure is treated as a mitigating factor and can reduce civil monetary penalties by up to 50%.

Noble Sight's immutable audit trail — the full screening response, alert lifecycle, analyst attribution, and resolution — provides the documentary evidence your legal team needs to prepare a self-disclosure. Every decision is traceable. Nothing is reconstructed after the fact.

SDN List Removal Petitions

If your customer believes they have been incorrectly placed on the SDN list, OFAC accepts petitions for removal. This is a formal legal process — not a customer service request. Petitions must demonstrate that the listing criteria no longer apply or were applied in error.

Noble Sight's screening history can support a petition by documenting the entity's match history, score trends over time, and any secondary attribute mismatches that were flagged.

OFAC petition process →

Dispositions

When closing an alert as a true match, you must record a disposition — the action your institution took. This maps to OFAC reporting obligations (31 C.F.R. Part 501).

Disposition Meaning OFAC Reporting
blocked Transaction or property blocked Blocking report required within 10 business days
rejected Transaction rejected (wire, ACH, trade) Reject report required within 10 business days
permitted_gl Permitted under a General License Must cite the specific GL (e.g., "GL-2023-001")
no_action_required Match confirmed but no reportable action Maintain documentation

When disposition is permitted_gl, the gl_citation field is required — documenting which General License authorizes the activity.

Four-Eyes Rule

Alerts scoring 90 or above require two different people to close. The analyst who last changed the status cannot be the one who resolves it. No single person can both investigate and dismiss a high-confidence match — the system enforces this at the API level.

Reason Codes

Structured reason codes replace free-text dismissals. Examiners can query and aggregate dismissal patterns across your entire alert history — showing that your compliance process is systematic, not ad hoc.

CodeMeaning
false_positive_name_onlyName similarity only, no corroborating data
false_positive_dob_mismatchDate of birth does not match
false_positive_country_mismatchCountry or nationality does not match
false_positive_entity_type_mismatchEntity type mismatch (e.g., individual vs. vessel)
false_positive_otherOther reason (include details in note)
true_matchConfirmed sanctions match

Webhooks

Screening finds matches. Alerts track decisions. Webhooks deliver them to you in real time. Register an HTTPS endpoint, and Noble Sight POSTs every new alert to it — signed with HMAC-SHA256 so you can verify authenticity. Retry delivery handles transient failures. Standard tier and above.

POST /v1/webhooks

Register a webhook URL to receive alert notifications. Provide a shared secret (minimum 32 characters) for HMAC-SHA256 signature verification. The secret is encrypted at rest with AES-256-GCM — Noble Sight never stores it in plaintext.

curl -X POST https://noblesight.io/v1/webhooks \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "url": "https://your-app.com/webhooks/noble",
    "secret": "your-shared-secret-minimum-32-characters-long"
  }'

Response:

{
  "id": 1,
  "url": "https://your-app.com/***",
  "status": "active",
  "created_at": "2026-03-23T10:00:00Z"
}

URLs are masked in all responses after creation — only the host is shown. Maximum 5 active webhooks per tenant.

GET /v1/webhooks

List active webhooks for your tenant.

curl https://noblesight.io/v1/webhooks \
  -H "X-API-Key: $NOBLE_API_KEY"

Response:

{
  "data": [{
    "id": 1,
    "url": "https://your-app.com/***",
    "status": "active",
    "created_at": "2026-03-23T10:00:00Z"
  }],
  "has_more": false
}

DELETE /v1/webhooks

Disable a webhook. Soft delete — the record is preserved for audit trail, but no further deliveries are attempted.

curl -X DELETE "https://noblesight.io/v1/webhooks?id=1" \
  -H "X-API-Key: $NOBLE_API_KEY"

Returns 204 No Content on success.

Event Payload

When an alert is created — from a screening, batch job, or portfolio monitoring hit — Noble Sight POSTs a JSON event to every active webhook for your tenant.

{
  "id": "evt_a1b2c3d4e5f6g7h8",
  "type": "alert.created",
  "created_at": "2026-03-23T10:05:00Z",
  "data": {
    "alert_id": 42,
    "screened_name": "Vladimir Putin",
    "score": 97.5,
    "source": "api",
    "sdn_uid": "36095"
  }
}

Signature Verification

Every webhook delivery includes two headers: X-Noble-Signature (HMAC-SHA256) and X-Noble-Timestamp (Unix seconds). The signature covers timestamp + "." + body. Verify the signature, then reject deliveries where the timestamp is more than 5 minutes old to prevent replay attacks.

# Verify in your webhook handler:
# 1. Read X-Noble-Timestamp and the raw request body
# 2. Compute HMAC-SHA256 over timestamp + "." + body with your secret
# 3. Compare against the X-Noble-Signature header (constant-time)
# 4. Reject if timestamp is more than 5 minutes old (replay protection)
#
# Header format: sha256=<hex-encoded HMAC>
#
# Example (Go):
#   timestamp := r.Header.Get("X-Noble-Timestamp")
#   mac := hmac.New(sha256.New, []byte(secret))
#   mac.Write([]byte(timestamp))
#   mac.Write([]byte("."))
#   mac.Write(body)
#   expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
#   if !hmac.Equal([]byte(expected), []byte(header)) { reject }
#   ts, _ := strconv.ParseInt(timestamp, 10, 64)
#   if time.Since(time.Unix(ts, 0)) > 5*time.Minute { reject }

Always use constant-time comparison (hmac.Equal in Go, crypto.timingSafeEqual in Node.js) to prevent timing attacks. The 5-minute window accommodates clock skew and retry backoff while stopping replay attacks.

Delivery and Retry

AspectDetail
AttemptsUp to 8 delivery passes on an exponential backoff schedule spanning roughly 10 hours (30s → 6h between passes). Retry state is persisted, so retries survive server restarts — an endpoint down for a deploy or a multi-hour outage still receives its alerts.
SuccessAny 2xx response. The delivery is marked delivered and no further attempts occur. With multiple endpoints registered, each endpoint stops receiving retries as soon as it accepts — retries continue only to endpoints that have not.
FailureNon-2xx responses or network errors trigger the next pass. After all passes are exhausted, the delivery is marked failed.
DeduplicationEvery attempt for an event carries the same X-Noble-Event-ID (matching the payload id). Delivery is at-least-once — treat the event id as your idempotency key.
Timeout10 seconds per attempt. Your endpoint must respond within this window.
RedirectsNot followed. Webhook URLs must resolve directly — this prevents SSRF via redirect chains.
Audit trailEvery delivery attempt is recorded: status, HTTP response code, attempt count, and error message. Available for compliance review.
URL validationHTTPS required. Private IPs, loopback addresses, and localhost are blocked to prevent SSRF.

Webhook delivery is notification only. If delivery fails after all retries, the alert still exists in your alert queue and is still visible via GET /v1/alerts. A webhook failure never suppresses, delays, or alters an alert. This matters for examination readiness: your compliance team can always demonstrate that every alert was created, recorded, and available for review regardless of notification channel status.

Webhooks vs. Polling

Two ways to consume alerts. Use whichever fits your architecture — or both.

ApproachHowBest For
Webhooks Register a URL. Noble Sight POSTs every alert to you in real time. Real-time processing. Event-driven architectures. Minimal latency between alert creation and your team seeing it.
Polling Call GET /v1/alerts?after=<last_id> on a schedule. Store the last-seen ID. Simpler infrastructure. No public endpoint needed. Firewall-friendly. Batch processing workflows.

Portfolio

Add your customers to a monitored portfolio. When sanctions lists change, Noble Sight automatically re-screens them using reverse screening — only the changed sanctions entries are checked against your portfolio, not the other way around. Fast, targeted, always current. Add-on for any paid tier.

GET /v1/portfolio

List all active entities in your monitored portfolio.

curl "https://noblesight.io/v1/portfolio?limit=25" \
  -H "X-API-Key: $NOBLE_API_KEY"

Cursor-paginated by id ascending. When has_more is true, request the next page with ?after=<id of the last entity in data>.

Response:

{
  "data": [
    {
      "id": 101,
      "full_name": "Dmitry Medvedev",
      "country": "RU",
      "entity_type": "individual",
      "highest_score": 94.2,
      "average_score": 87.1,
      "last_screened_at": "2026-03-09T12:00:00Z",
      "created_at": "2026-01-15T09:30:00Z"
    },
    {
      "id": 102,
      "full_name": "Acme Trading LLC",
      "entity_type": "entity",
      "highest_score": 0,
      "average_score": 0,
      "last_screened_at": "2026-03-09T12:00:00Z",
      "created_at": "2026-02-01T14:00:00Z"
    }
  ],
  "has_more": false
}

POST /v1/portfolio

Add an entity to your monitored portfolio. It is screened immediately against current sanctions lists, then re-screened automatically whenever lists change.

curl -X POST https://noblesight.io/v1/portfolio \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "full_name": "Dmitry Medvedev",
    "date_of_birth": "1965-09-14",
    "country": "RU"
  }'

PUT /v1/portfolio

Update an existing portfolio entity. Use this when customer data changes (e.g., corrected name spelling or updated country).

curl -X PUT https://noblesight.io/v1/portfolio \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "full_name": "Dmitrii Medvedev",
    "date_of_birth": "1965-09-14",
    "country": "RU"
  }'

DELETE /v1/portfolio

Remove an entity from monitoring. It will no longer be re-screened when sanctions lists update. Historical screening records are retained for audit — nothing is deleted.

curl -X DELETE "https://noblesight.io/v1/portfolio?full_name=Dmitry+Medvedev" \
  -H "X-API-Key: $NOBLE_API_KEY"

How Reverse Screening Works

AspectDetail
TriggerOFAC publishes a new list version. Noble detects the delta (added, modified, or removed entries).
DirectionChanged SDN names are screened against all active portfolio entities. Removed entries don't trigger screening.
MatchingSame trigram + Soundex engine as standard screening.
AlertsMatches generate alerts with score, match details, SDN UID, and the publish ID of the list version that triggered it.
DedupEntities are deduplicated by client_id + full_name (case-insensitive).

Pricing: $0.50/entity/mo (up to 10K), $0.35 (10K-50K), $0.20 (50K+).

Watchlist

Government sanctions lists are public. The risks your institution has already identified are not. Every bank maintains internal lists — names from filed SARs, de-risked customers, law enforcement inquiries, and compliance investigations. These lists represent hard-won institutional knowledge that no government authority publishes. Noble Sight screens against them automatically, alongside OFAC, UK, and other government lists, in the same API call. Standard tier and above.

Two Kinds of Lists

Noble Sight screens against two categories of data in every request:

CategoryWhat It IsWho Maintains It
Government lists Public sanctions and regulatory designations — OFAC SDN, OFAC Consolidated, UK Sanctions, EU Sanctions, FinCEN 311 Special Measures, UN Consolidated List. Published by government authorities, machine-readable, updated continuously. Noble Sight ingests these automatically. You don't upload anything — we watch the source, detect changes, and delta-import within minutes.
Internal watchlists Your institution's own intelligence — SAR subjects, exited customers, 314(a) names, law enforcement inquiries, adverse media flags, correspondent risk. Confidential by nature, institution-specific, not available from any public source. You manage these via the /v1/watchlist API. Noble Sight screens against them with the same matching engine used for government lists — trigram, phonetic, and AI-powered matching.

Why Internal Watchlists Matter

A sanctions list tells you who the government has designated. An internal watchlist tells you who your institution has flagged — and that intelligence is often more actionable than a public list.

Consider: a customer was exited two years ago for structuring. They apply for a new account under a slightly different name. OFAC screening returns nothing — they're not sanctioned. But your internal watchlist catches the match because the name is still there, with the reason and case number attached. Without that list in your screening pipeline, the onboarding proceeds and the risk walks back in.

The FFIEC BSA/AML Examination Manual expects institutions to maintain systems that identify and monitor suspicious activity on an ongoing basis. Examiners will ask how you screen new customers against your own prior findings — not just government lists. A watchlist that lives in a spreadsheet or a separate system that nobody queries during onboarding is a control gap. A watchlist that is checked on every screening call, with the same fuzzy matching engine, is a control.

What Goes on an Internal Watchlist

These are sources of risk intelligence that are confidential, institution-specific, and cannot be published or aggregated by any third party. The only way to screen against them is for your institution to maintain them.

SourceWhat It IsWhy It Matters
SAR Subjects Individuals and entities named in your institution's Suspicious Activity Reports — filings submitted to FinCEN when you detect potential money laundering, fraud, terrorist financing, or other criminal activity. U.S. institutions file over 4 million SARs per year. These names are your own intelligence about suspicious behavior, and SAR confidentiality rules (31 USC 5318(g)(2)) prohibit sharing them outside your institution. If you filed a SAR on someone, you should know if they — or someone with a similar name — attempt to open a new account or transact again. Most banks track SAR subjects in spreadsheets or siloed case management systems. Putting them in a watchlist that is screened automatically closes that gap.
Exited Customers Customers whose accounts were closed due to suspicious activity, compliance violations, or risk appetite decisions. De-risking is a significant action — the decision was made for a reason. Exited customers sometimes return under variant names, through different branches, or through correspondent relationships. If the original exit decision was risk-based, the risk hasn't changed because the account was closed.
FinCEN 314(a) Names Section 314(a) of the USA PATRIOT Act authorizes FinCEN to send financial institutions lists of names on behalf of federal law enforcement agencies investigating money laundering and terrorism. Institutions must search their records and report matches within 14 days. These requests arrive roughly every two weeks and contain names of active investigative targets. 314(a) data is confidential — institutions cannot share or redistribute it. 314(a) names are time-sensitive law enforcement intelligence. Adding them to your watchlist ensures they are screened not just once during the initial search, but on every future screening call — catching the target if they attempt new activity at your institution after the initial 314(a) search window closes.
Law Enforcement Inquiries Names from subpoenas, grand jury requests, National Security Letters, or informal law enforcement inquiries. These indicate that a person or entity is under active investigation, even if they have not been charged. An active investigation means elevated risk. Screening future transactions against these names provides early warning if the subject re-engages with your institution.
Adverse Media Entities flagged through your adverse media monitoring program — news reports of fraud, corruption, money laundering, or other financial crime. A person may not appear on any government list but may be widely reported as under investigation or indictment. No single authoritative public list exists for adverse media — it depends on your institution's monitoring sources and risk criteria. Adverse media often leads sanctions designations by months or years. Adding flagged names to your watchlist provides early screening coverage before the government acts.
Correspondent Risk Financial institutions in high-risk jurisdictions, under regulatory action, or flagged by your correspondent banking due diligence process. Screening wire originators and beneficiaries against your correspondent risk list catches transactions routed through institutions you've already identified as elevated risk.

POST /v1/watchlist

Add an entry to your internal watchlist. The name is available for screening immediately. Phonetic codes are computed at insert time for fast fuzzy matching.

curl -X POST https://noblesight.io/v1/watchlist \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{
    "full_name": "John Doe",
    "list_name": "SAR Subjects",
    "reason": "SAR filed 2026-03-01, case #2026-0042",
    "entity_type": "individual",
    "country": "US"
  }'

Response:

{
  "id": 17,
  "full_name": "John Doe",
  "list_name": "SAR Subjects",
  "reason": "SAR filed 2026-03-01, case #2026-0042",
  "entity_type": "individual",
  "country": "US",
  "added_by": "apikey:royal_bank",
  "created_at": "2026-03-16T19:00:00Z",
  "status": "active"
}

Request Fields

FieldTypeRequiredDescription
full_namestringYesName to screen against (max 500 chars)
list_namestringNoCategory label (default: "default"). Use it to organize entries by source: "SAR Subjects", "Exited Customers", "314a Names", etc.
reasonstringNoWhy this entity is on the list. Strongly recommended — this is what an examiner will ask about.
id_numberstringNoGovernment identifier (passport, SSN, tax ID)
id_typestringNoType of identifier (e.g., "Passport", "SSN")
countrystringNoCountry code (ISO 3166-1 alpha-2)
entity_typestringNo"individual" or "entity" (default: "individual")
expires_atstringNoRFC 3339 expiration date. Entry stops matching after this date. Useful for time-limited law enforcement requests.

GET /v1/watchlist

List your watchlist entries. Filter by list category or status. Paginated.

curl "https://noblesight.io/v1/watchlist?list_name=SAR+Subjects&limit=25" \
  -H "X-API-Key: $NOBLE_API_KEY"

Response:

{
  "data": [
    {
      "id": 17,
      "full_name": "John Doe",
      "list_name": "SAR Subjects",
      "reason": "SAR filed 2026-03-01, case #2026-0042",
      "entity_type": "individual",
      "country": "US",
      "added_by": "apikey:royal_bank",
      "created_at": "2026-03-16T19:00:00Z",
      "status": "active"
    }
  ],
  "has_more": false
}

Query Parameters

ParameterDefaultDescription
list_nameall listsFilter by list category
statusactiveFilter by status: active or removed
after0Cursor: return entries with id greater than this (pass the id of the last entry in the previous page's data)
limit50Results per page (max 100)

DELETE /v1/watchlist

Remove a watchlist entry. This is a soft delete — the entry is marked removed and excluded from future screening, but the record is preserved for audit trail. Nothing is ever hard-deleted.

curl -X DELETE "https://noblesight.io/v1/watchlist?id=17" \
  -H "X-API-Key: $NOBLE_API_KEY"

Returns 204 No Content on success. Returns 404 if the entry doesn't exist or belongs to a different tenant.

How Watchlist Screening Works

AspectDetail
AutomaticEvery POST /v1/screen request checks your watchlist alongside all active government lists. No opt-in flag, no separate API call. One request screens everything.
MatchingSame trigram + Soundex engine as sanctions screening. A slightly misspelled name on your watchlist still matches — because the same person with a typo is still the same risk.
ScoringSame formula as sanctions matches. An exact watchlist hit scores 100. Partial matches score proportionally.
SourceWatchlist matches appear with source: "Internal Watchlist" and match_sources[].source: "internal_watchlist". The list name and reason are included in the match remarks.
ExpirationEntries with an expires_at date are automatically excluded from screening after that date. No manual cleanup needed.
Tenant isolationEach API key screens against its own watchlist only. Your entries are never visible to other tenants.
AlertsWatchlist matches generate alerts through the same workflow as sanctions matches — same state machine, same four-eyes rule, same audit trail.

Audit

Every screening decision is stored immutably — the full request, full response, list version, and trace ID. No record is ever modified or deleted. Designed for OFAC's 10-year recordkeeping requirement and SOC 2 audit readiness.

What Gets Stored

Every call to /v1/screen creates an immutable record. Pass your own trace ID via the X-Trace-ID header to link screening decisions to your internal workflows:

curl -X POST https://noblesight.io/v1/screen \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -H "X-Trace-ID: onboarding-customer-42" \
  -d '{"name": "Test Name"}'
Field Description
trace_id Your X-Trace-ID header value, or auto-generated UUID
request Full screening request (name, DOB, country, deep_screen flag)
response Complete match results with scores and match sources
ofac_publish_id The exact OFAC SDN list version used for this screening
screened_at UTC timestamp of the screening decision

GET /v1/export

Export a single screening decision by trace ID. Standard tier and above.

curl "https://noblesight.io/v1/export?trace_id=onboarding-customer-42" \
  -H "X-API-Key: $NOBLE_API_KEY"

GET /v1/export/bulk

Bulk export screening decisions by date range for regulatory audits. Requires from and to (YYYY-MM-DD; to inclusive).

curl "https://noblesight.io/v1/export/bulk?from=2026-01-01&to=2026-03-01&limit=100" \
  -H "X-API-Key: $NOBLE_API_KEY"

Cursor-paginated by record id ascending; returns the { "data": [...], "has_more": bool } envelope. When has_more is true, pass ?after=<id of the last record in data>. A full audit pull uses the async export job.

OFAC List Version Tracking

Every screening result records which OFAC list version was active at the time of the screen. When an examiner asks "what list were you screening against on March 1?" — the answer is in the audit record, not a reconstruction.

Webhook Delivery Records

Every webhook delivery attempt is recorded with status, HTTP response code, attempt count, error message, and timestamps. These records are part of the audit trail — when an examiner asks "how was your compliance team notified of this alert?", the delivery log provides the answer: which endpoint received the notification, when it was delivered, and whether retries were needed.

Pricing

Screen any name against 18,598+ sanctioned entities. Every tier includes explainable scores and immutable audit trails. Pay only when you need more volume or deeper analysis.

Feature Free Standard Premium Enterprise
Price $0/mo $199/mo $499/mo Custom
Screenings 100/day 5,000/mo 25,000/mo 100,000+/mo
Sanctions screening Yes Yes Yes Yes
Explainable scores Yes Yes Yes Yes
Match source breakdown Yes Yes Yes Yes
Alert management Yes Yes Yes Yes
Audit trail persistence Yes Yes Yes Yes
Deep screen (AI expansion) Yes Yes Yes
Batch screening Up to 1,000 Up to 5,000 Up to 10,000
Webhook notifications Yes Yes Yes
Compliance exports Yes Yes Yes
Portfolio monitoring Yes Yes
Dedicated support Yes
Custom integration Yes

What every tier includes

Three layers of matching across every live sanctions list: character similarity, phonetic matching, and 89,117 pre-computed AI name variations. Secondary attribute scoring — date of birth and country — suppresses false positives automatically. Every result includes the score, the match sources, the dismissal reasons, and the list version. Persisted for 10 years.

Most vendors charge thousands per month for less transparency than the free tier provides.

Standard — $199/mo

5,000 screenings per month. Batch screening up to 1,000 names per job. Deep screen sends your search input through real-time Gemini AI expansion — catching transliterations and cultural variants that pre-computed variations miss. Webhook notifications deliver alerts to your endpoint in real time with HMAC-SHA256 signed payloads. Compliance exports let you pull screening decisions by trace ID or date range for regulatory audits.

Premium — $499/mo

25,000 screenings per month. Batch screening up to 5,000 names per job. Everything in Standard, plus portfolio monitoring — register customer names and get automatically re-screened when sanctions lists update. When OFAC adds a new SDN entry, you know within minutes if any of your customers match. Not days. Minutes.

Enterprise

100,000+ screenings per month. Batch screening up to 10,000 names per job. Dedicated support, custom integration, and volume pricing. Contact sales@noblesight.io.

Upgrade via API

No sales calls required for Standard or Premium. One API call creates a Stripe Checkout session. See Account for the endpoint.

Account

View your tier, usage, and billing status. Upgrade from free to a paid plan without contacting sales.

GET /v1/account

Returns your current tier, usage within the rate limit window, billing status, and which features are available. One call instead of guessing.

curl https://noblesight.io/v1/account \
  -H "X-API-Key: $NOBLE_API_KEY"
{
  "client_id": "acme-bank",
  "key_prefix": "noble_live_a1b2",
  "tier": "free",
  "billing_status": "none",
  "usage": {
    "used": 42,
    "limit": 100,
    "remaining": 58,
    "window": "day"
  },
  "features": {
    "screening": true,
    "deep_screen": false,
    "batch_screening": false,
    "portfolio_monitoring": false,
    "compliance_exports": false,
    "webhook_notifications": false,
    "alert_management": true,
    "max_batch_size": 0
  }
}

POST /v1/account/checkout

Create a Stripe Checkout session to upgrade your API key to a paid tier. Returns a URL — redirect your user or open it in a browser.

curl -X POST https://noblesight.io/v1/account/checkout \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $NOBLE_API_KEY" \
  -d '{"tier": "standard"}'
{
  "checkout_url": "https://checkout.stripe.com/c/pay/..."
}

On successful payment, your API key tier is upgraded automatically. No downtime, no key rotation. See Pricing for tier details.

Support

Integration help, compliance questions, or paid tier inquiries. We respond within one business day.

support@noblesight.io  ·  sales@noblesight.io for paid tiers  ·  Bluesky

System Status

Monitor Noble Sight's availability programmatically. No authentication required.

curl https://noblesight.io/status | jq

Returns 200 when operational, 503 during outages. Includes database latency and component health. Point your uptime monitor here.