{"entries":[{"date":"2026-08-16","category":"added","summary":"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","details":"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.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"$NAME\"}' | jq '{status: .ownership.status, pct: .ownership.cumulative_percent, chains: .ownership.chains, alert: .ownership_alert_id}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"$NAME\",\"as_of\":\"2026-08-01\"}' | jq '.ownership'"],"expect":"a live screening returns an ownership object whose status is blocked, review, clean, or unknown — blocked determinations carry cumulative_percent \u003e= 50, the evidence chains with per-hop sources, and an ownership_alert_id; the as_of screening returns null ownership (historical inquiries never carry current ownership data)","audit":"the ownership determination is persisted in the screening's immutable audit record exactly as returned, and each determination pins the source snapshots it was computed from (as_of plus source_publish_ids), so an examiner can reproduce the evidence behind any blocked decision","regression":"TestScreenOwnershipEndToEnd proves a name on no list but majority-owned by sanctioned parties returns status blocked with chains, raises the ownership alert, and lands in the audit record; TestOwnershipResolver locks all four determinations; TestOwnershipRecompute locks the FAQ 401 chain semantics behind the precomputation"}},{"date":"2026-08-15","category":"changed","summary":"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","details":"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.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"$NAME\",\"country\":\"SY\"}' | jq '.jurisdiction_risk'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"$NAME\",\"country\":\"IR\"}' | jq '{jurisdiction: .jurisdiction_risk.jurisdiction, program: .jurisdiction_risk.program, authority: .jurisdiction_risk.authority}'"],"expect":"Syria returns null jurisdiction_risk (no comprehensive embargo to signal); Iran still returns the advisory signal with its program and citation (31 CFR Part 560) — as do Cuba, North Korea, and the embargoed Ukrainian regions","audit":"each screening's persisted record includes the jurisdiction_risk signal (or its absence) as returned at decision time, so pre- and post-reconciliation decisions each reflect the programs in force when they were made","regression":"TestDetectJurisdiction locks the reconciled table both ways — the three country programs and four Ukrainian regions fire with complete program citations, while Syria post-revocation and the never-determined Kherson/Zaporizhzhia regions stay silent"}},{"date":"2026-08-15","category":"changed","summary":"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","details":"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 \u0026 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.","endpoint":"/v1/lists","verification":{"steps":["curl -s \"$BASE/v1/lists\" | jq '.lists[] | select(.list==\"uk\") | {display_name, publish_date, record_count, imported_at, status}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"$NAME\",\"lists\":[\"uk\"]}' | jq '{lists_screened, list_versions}'"],"expect":"the uk list reports display_name \"UK Sanctions List (FCDO)\", a 2026 publish_date, a record_count around 6,300, and a fresh imported_at; screening restricted to uk returns the pinned uk list version alongside any matches — designations carrying only non-asset-freeze measures (e.g. travel bans) now match","audit":"every screening decision persists the exact UK list version it ran against, and the cutover import is recorded in the list's changelog and archive tables, so pre- and post-migration decisions are both reproducible against their respective snapshots","regression":"TestUKFreshLoadDeltaRoundTrip imports the full FCDO list and proves a clean zero-change re-import; TestParseXML and TestParseXMLFailClosed lock the new format's parsing, date normalization, and fail-closed identity guards; TestParseXMLLiveFixture asserts structural invariants against the live feed"}},{"date":"2026-08-15","category":"added","summary":"Canada (SEMA) and Australia (DFAT) consolidated sanctions lists are live — both appear in GET /v1/lists and participate in screening and portfolio monitoring","details":"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.","endpoint":"/v1/lists","verification":{"steps":["curl -s \"$BASE/v1/lists\" | jq '[.lists[] | select(.list==\"canada\" or .list==\"australia\") | {list, status, record_count, imported_at}]'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"$NAME\",\"lists\":[\"canada\",\"australia\"]}' | jq '.lists_screened'"],"expect":"both lists appear with a record_count in the thousands and a recent imported_at; screening with the lists parameter restricted to canada/australia returns matches attributed to those sources","audit":"every screening decision persists the sanctions list versions used, so records show exactly which Canada/Australia publisher snapshot each result was screened against","regression":"TestCanadaFreshLoadDeltaRoundTrip imports the full live-format SEMA list and proves a clean re-import; TestParseXMLRejectsDuplicateSids and TestBuildScheduleItem lock the entry-identity model against the live feed's format; TestDownloadLive (network-gated) proves the DFAT fetch end to end"}},{"date":"2026-08-15","category":"added","summary":"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","details":"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.","endpoint":"/status","verification":{"steps":["curl -s \"$BASE/status\" | jq '{status, lists: .components.lists}'","curl -s \"$BASE/v1/lists\" | jq '[.lists[] | {list, status}]'"],"expect":"the status response includes components.lists; when every list in /v1/lists reports \"ok\" the component is \"operational\" and overall status \"operational\" — if any list reports \"stale\", the component and overall status read \"degraded\" while screening endpoints keep responding","audit":"both endpoints derive from the persisted per-list ingestion heartbeat, so the status shown to a dashboard at any moment is reproducible from the recorded list versions and their poll timestamps","regression":"TestStatusListsComponent drives a list's ingestion heartbeat stale and proves /status degrades and then recovers with the heartbeat; TestFreshnessContractMatchesListhealth locks /status, /v1/lists, and the internal staleness alarm to one shared freshness computation"}},{"date":"2026-08-14","category":"fixed","summary":"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","details":"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.","endpoint":"/v1/batch","verification":{"steps":["curl -s \"$BASE/v1/batch/status?id=$JOB_ID\" -H \"X-API-Key: $KEY\" | jq '[.items[] | select(.status==\"screened\" and .highest_score\u003e=80 and ((.alert_id // 0)==0))]'"],"expect":"an empty array — no completed batch contains an item that screened at or above your tenant's alert threshold (default 80) without a linked alert_id; items whose alert could not be recorded appear with status \"error\" and can be re-submitted","audit":"batch item statuses and their alert links are part of the persisted batch record; an error item leaves no orphaned screened result, so the batch report and the alert queue can never disagree about which matches await review","regression":"TestBatchAlertFailureFailsItem forces an alert-creation failure through the real batch path and proves the item lands in error status with no alert link, while a clean run screens and links its alert"}},{"date":"2026-08-14","category":"fixed","summary":"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","details":"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.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"$NAME\",\"country\":\"XX\"}' | jq '{score: .summary.top_match.score, country_signals: [.matches[].dismissal_signals[]? | select(.field==\"country\")]}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"$NAME\"}' | jq '.summary.top_match.score'"],"expect":"both requests return the same top-match score and the unrecognized code produces no country dismissal signals; a recognized code (for example US against a Russian national) still applies the documented country handling","audit":"the persisted screening record preserves the request country and every dismissal signal, so an examiner can confirm which country comparisons ran for any historical decision","regression":"TestCompareCountryUnmappedCode locks that unknown codes never penalize while recognized-code mismatches still do; TestIsoToCountry locks the expanded code set"}},{"date":"2026-08-14","category":"fixed","summary":"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","details":"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.","endpoint":"/v1/alerts","verification":{"steps":["curl -s \"$BASE/v1/alerts/$ALERT_ID\" -H \"X-API-Key: $KEY\" | jq '.match_payload[0].Sources[0] | {Variation, Reason, MatchedText}'"],"expect":"for a monitoring alert raised because a list change matched an enrolled name through a stored variant, Variation holds the variant text that matched and Reason states why it was generated (for example a transliteration); for primary-name and alias matches both fields are empty, and MatchedText always shows which listed text was screened","audit":"each variation screened by monitoring persists its own evidence in the same append-only screening record store as point-in-time screens — including the variant text and its generation reason — for the 10-year retention window","regression":"TestReverseScreenAIVariationParity proves a variation-only match alerts identically through point-in-time screening and portfolio monitoring, with equal scores and the variant attributed on the alert; TestReverseScreenVariationExpansionOnlySDN locks which list's changes expand"}},{"date":"2026-08-14","category":"fixed","summary":"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","details":"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.","endpoint":"/v1/alerts","verification":{"steps":["curl -s \"$BASE/v1/alerts/$ALERT_ID\" -H \"X-API-Key: $KEY\" | jq '[.activity[] | select(.action==\"list_event\")]'"],"expect":"for a monitoring alert whose subject appears in a later list update, the activity timeline contains a list_event entry with actor \"system:monitoring\" and a note naming the change type, source list, and publish id; if the same person is designated on a second sanctions list while the alert is open, GET /v1/alerts shows a separate open alert for that list rather than one merged alert","audit":"list_event entries live in the same append-only case-activity trail as status changes and notes — they cannot be updated or deleted, and they export with the alert's full timeline","regression":"TestMonitoringDedupRecordsListEvent proves an absorbed list event appears on the open alert's timeline via the API; TestCrossListDesignationCreatesDistinctAlert proves a second list's designation raises its own alert while same-list repeats still deduplicate"}},{"date":"2026-08-13","category":"fixed","summary":"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","details":"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 \u003c 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.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\",\"deep_screen\":true}' | jq '{deep_screen_coverage, deep_screen_fallback_reason}'"],"expect":"deep_screen_coverage is present with variations_generated equal to variations_screened on a fully delivered expansion (deep_screen_fallback_reason absent); if any generated variation could not be screened, variations_screened is lower and deep_screen_fallback_reason is \"variation_screening_failure\"; if the expansion could not run at all, deep_screen_fallback_reason is \"llm_failure\" and no coverage object appears","audit":"the persisted screening record (GET /v1/export?trace_id=) preserves the full response, including the coverage counts and any fallback reason, for the 10-year retention window","regression":"TestDeepScreenCoverage proves every generated-vs-screened combination yields visible counts and an explicit fallback on any shortfall; TestScreenDecodesDeepScreenCoverage locks the client-side decode"}},{"date":"2026-08-13","category":"fixed","summary":"Webhook delivery now covers portfolio-monitoring alerts and retries durably: every alert source pushes, retry state survives restarts, and retries span roughly 10 hours instead of 31 seconds","details":"Previously webhooks fired only for alerts created by POST /v1/screen and POST /v1/batch — an alert raised by portfolio monitoring (a sanctions-list update matching an enrolled entity) was visible only by polling GET /v1/alerts. Delivery also retried for only ~31 seconds in process memory, so an endpoint down for a routine deploy permanently missed the delivery, and a server restart could drop pending batch deliveries. Now every created alert — api, batch, and monitoring — is enqueued to a durable outbox at creation time and delivered with up to 8 passes on a persisted backoff spanning roughly 10 hours. Retry state survives restarts; an endpoint that already accepted an event is never re-POSTed while other endpoints retry; and every attempt carries the same evt_* identifier in both the payload id and the X-Noble-Event-ID header (these previously disagreed), so consumers can deduplicate at-least-once delivery. A delivery failure never suppresses the alert — GET /v1/alerts remains the system of record. Additive — no version change.","endpoint":"/v1/webhooks","verification":{"steps":["curl -s -X POST \"$BASE/v1/webhooks\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"url\":\"https://your-endpoint.example.com/hooks\",\"secret\":\"a-shared-secret-at-least-32-chars-long\"}' | jq '{id, status}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\"}' | jq '{alert_id}'"],"expect":"within seconds your endpoint receives an alert.created event for the returned alert_id whose payload id equals its X-Noble-Event-ID header; if your endpoint is down it receives the same event (same id) on a later pass — including after a Noble server restart — rather than losing it; alerts created by portfolio monitoring arrive at the same endpoint with source \"monitoring\"","audit":"every attempt is recorded per endpoint with status, HTTP response code, attempt count, and error; the alert remains in GET /v1/alerts regardless of delivery outcome","regression":"tests/webhook_delivery_test.go — TestReverseScreenMonitoringDeliversWebhook drives a sanctions-list delta through the reverse-screen job to a signed delivery; TestWebhookOutboxRetriesAcrossRestart proves retry state survives a process restart"}},{"date":"2026-08-13","category":"fixed","summary":"Webhook notifications are enabled on the hosted deployment: POST /v1/webhooks registers endpoints and alert delivery is active (Standard tier and above)","details":"The hosted deployment previously rejected webhook registration with a 503 (\"webhooks not configured\") because the delivery subsystem was not configured. Registration and delivery now work as documented: HMAC-SHA256-signed payloads verified via X-Noble-Signature, endpoint secrets encrypted at rest, automatic retries with exponential backoff, and a per-attempt delivery record. A webhook failure never suppresses an alert — the alert queue remains the system of record. Additive — no version change.","endpoint":"/v1/webhooks","verification":{"steps":["curl -s -X POST \"$BASE/v1/webhooks\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"url\":\"https://example.com/hooks/noble\",\"secret\":\"a-shared-secret-at-least-32-chars-long\"}' | jq '{id, url, status}'","curl -s \"$BASE/v1/webhooks/stats\" -H \"X-API-Key: $KEY\" | jq '{total, remaining, limit}'"],"expect":"registration returns a webhook id with status \"active\" (not a 503 \"webhooks not configured\" error) for a Standard-tier-or-above key, with the URL masked in the response; stats shows the registration counted against the per-tenant limit","audit":"every delivery attempt for the registered endpoint is recorded with status, HTTP response code, attempt count, and error — webhook delivery records are part of the tenant's audit surface"}},{"date":"2026-08-13","category":"fixed","summary":"Coverage documentation corrected to match GET /v1/lists: OFAC Consolidated (Non-SDN) is live, headline counts now say 8 live lists, and Canada and Australia are shown as planned","details":"The docs page, marketing page, and llms.txt had drifted from the API in both directions: OFAC Consolidated (Non-SDN) is imported and screened but was still labeled planned, while headline counts claimed 10 live lists even though Canada (SEMA) and Australia (DFAT) are planned and do not yet appear in GET /v1/lists. All surfaces now state what the API states: 8 lists live — OFAC SDN, OFAC Consolidated (the two share the single ofac entry in GET /v1/lists), UK, EU, UN, France, Belgium, Netherlands — with Canada and Australia planned. Documentation only — screening behavior, scoring, and API responses are unchanged. Additive — no version change.","endpoint":"/v1/lists","verification":{"steps":["curl -s \"$BASE/v1/lists\" | jq -r '.lists[].list'","curl -s \"$BASE/v1/lists\" | jq -r '.lists[] | select(.list==\"ofac\") | .display_name'"],"expect":"exactly seven entries — belgium, eu, france, netherlands, ofac, uk, un — with neither canada nor australia present, and the ofac entry's display_name is \"OFAC SDN + Consolidated\"; this matches the coverage table on the docs page (8 live rows, with OFAC SDN and OFAC Consolidated listed separately, and Canada and Australia marked Planned)"}},{"date":"2026-08-06","category":"added","summary":"Point-in-time replay: POST /v1/screen accepts as_of to screen against the sanctions lists as they stood at a past instant, and POST /v1/export/replay re-runs a stored screening pinned to its original list state and reports a field-level comparison verdict","details":"Previously the audit trail recorded what the engine decided, but nothing could prove the decision reproduces: the temporal schema existed with no code path to screen against a historical snapshot. Now: (a) POST /v1/screen accepts as_of (RFC 3339, or YYYY-MM-DD meaning end of day UTC) — the screening evaluates against list rows effective at that instant, the response carries screened_as_of and the as-of list_versions, and the request is persisted to the audit trail but never creates alerts, enrolls the entity, or dispatches webhooks; (b) POST /v1/export/replay (Standard tier and above, client-isolated) loads a stored screening by trace_id or request_id, re-runs the exact compute path pinned to the record's screened_at, and returns verdict match|mismatch with field-level differences and explicit caveats for what cannot deterministically replay (tenant watchlist matches, deep_screen LLM variation influence, OFAC child-table detail). Scope: api-channel records only — batch and monitoring records return replay_unsupported_channel. Additive — no version change.","endpoint":"/v1/export/replay","verification":{"steps":["TRACE=$(curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\"}' | jq -r '.trace_id')","curl -s -X POST \"$BASE/v1/export/replay\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d \"{\\\"trace_id\\\":\\\"$TRACE\\\"}\" | jq '{verdict, differences, caveats}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\",\"as_of\":\"2026-08-01\"}' | jq '{screened_as_of, list_versions}'"],"expect":"the replay returns verdict \"match\" with no differences for a just-recorded screening (its list state has not changed); the as_of request returns screened_as_of \"2026-08-01T23:59:59.999999999Z\" (a date-only as_of means end of day UTC) and list_versions resolved to the imports active at that instant, not today's","audit":"GET /v1/export?trace_id=$TRACE shows the stored record replay compared against; an as_of screening's audit record persists the historical request verbatim, so the replay chain is itself replayable","regression":"tests/replay_test.go — inserts a list entry, screens, mutates the list, and asserts replay reproduces the original decision with the original list version"}},{"date":"2026-08-06","category":"added","summary":"GET /v1/alerts/resolutions/stats: observed false-positive rate by score band, aggregated from analysts' structured resolution reason codes","details":"Every alert resolution carries a structured reason code; this endpoint aggregates those dispositions into a measured FP rate per score band — the production counterpart to the fixture-derived FP benchmark, and the driving input for tuning your alert_threshold. Counting model: resolution events from the immutable case-activity trail joined to the alert's score; a reopened-and-reclosed alert contributes each closure, because the rate measures analyst decisions, not current alert states. Optional from/to (YYYY-MM-DD, inclusive) bound the resolution date. Additive — no version change.","endpoint":"/v1/alerts/resolutions/stats","verification":{"steps":["curl -s \"$BASE/v1/alerts/resolutions/stats\" -H \"X-API-Key: $KEY\" | jq '{total, false_positives, true_matches, observed_fp_rate, by_score_band}'"],"expect":"a disposition breakdown where observed_fp_rate equals false_positives/total and each by_score_band entry carries its own resolved/false_positives/true_matches/fp_rate; a tenant with no closed alerts gets zeroed totals, not an error","audit":"each counted disposition is an immutable case-activity row — GET /v1/alerts/activity?action=status_change returns the underlying events the stats aggregate","regression":"api/v1 resolution-stats handler tests + tests/ integration coverage landed in the same PR (#106)"}},{"date":"2026-08-06","category":"fixed","summary":"deep_screen/deep_summary no longer pin a retired Gemini model, and the audit trail records the exact serving model instead of the request alias","details":"The hardcoded gemini-2.5-flash pin 404s for newly created Gemini API keys, which surfaced as deep-screen fallbacks or 503 deep_screen_unavailable on affected deployments. AI calls now track the current stable Gemini model instead of a hardcoded pin, and summary.generated_by/model_version now report the exact serving model returned by the API rather than the requested alias — so the 10-year audit record names the model that actually produced the output. Additive — no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\",\"deep_summary\":true}' | jq '{generated_by: .summary.generated_by, model_version: .summary.model_version, fallback: .summary.fallback_reason}'"],"expect":"on an AI-configured deployment, generated_by is \"llm:\u003cserving-model-id\u003e\" with model_version naming a live model (never the retired gemini-2.5-flash); otherwise generated_by is \"deterministic\" with fallback_reason set — a 404-dead model can no longer be silently pinned","audit":"GET /v1/export?trace_id=\u003ctrace_id\u003e — the persisted summary block carries the same exact serving-model id","regression":"internal/gemini model-selection tests (#104)"}},{"date":"2026-08-04","category":"added","summary":"jurisdiction_risk: screenings whose country input touches a comprehensive OFAC embargo (Cuba, Iran, North Korea, Syria, Crimea/DNR/LNR regions) now return an advisory nexus signal on /v1/screen and per-item on /v1/batch","details":"Previously the country field fed only score penalties — it could lower a match score on mismatch but never raise a flag, so a request disclosing an Iran or Cuba nexus with a clean name returned no_significant_match and no signal at all. Now the same request also returns jurisdiction_risk: the canonical jurisdiction code (ISO 3166-1 alpha-2, or ISO 3166-2 for the embargoed Ukrainian regions UA-43/UA-40/UA-14/UA-09), jurisdiction name, OFAC program, regulatory citation, level (comprehensive_embargo), and fixed EDD guidance. Detection accepts alpha-2 codes, the Ukrainian region codes, and common English names; Ukraine (UA) and Russia (RU) themselves never fire — only the embargoed regions do. The signal is advisory nexus detection, not list matching: it is independent of matches, never alters scores or the summary verdict, and never blocks. It is persisted in the screening's immutable audit record and in each batch item's evidence record. Additive — no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"John Cleanname\",\"country\":\"IR\"}' | jq '.jurisdiction_risk'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"John Cleanname\",\"country\":\"US\"}' | jq '.jurisdiction_risk'"],"expect":"the IR request returns a jurisdiction_risk block with jurisdiction \"IR\", program \"Iranian Transactions and Sanctions Regulations\", authority \"31 CFR Part 560\", level \"comprehensive_embargo\", and EDD guidance — even though the name itself has no significant match; the US request returns null. GET /v1/export?trace_id= for the IR screening shows the same block persisted in the audit record","audit":"GET /v1/export?trace_id=\u003ctrace_id from the IR screening\u003e — response_payload.jurisdiction_risk carries the identical block, proving the disclosed nexus is part of the 10-year record, not just the wire response"}},{"date":"2026-07-30","category":"fixed","summary":"deep_screen no longer degrades silently: an unconfigured deployment returns 503 deep_screen_unavailable, and a runtime AI failure sets deep_screen_fallback_reason on the response","details":"Previously a deep_screen request could silently fall through to standard screening with zero signal — no latency difference, no deep_screen match sources, no error — when the deployment had no AI backend configured or the AI call failed at runtime (#62). A paid enhanced-recall feature must never quietly deliver standard recall. Now: (a) a deployment without an AI backend rejects deep_screen requests with 503 deep_screen_unavailable (retry without deep_screen, or contact support) — deterministic misconfiguration fails hard; (b) a transient runtime AI failure still returns standard-recall results but sets deep_screen_fallback_reason on the response (values mirror the summary fallback_reason constants), so callers can detect and retry — the same honesty contract deep_summary has always had. Additive — no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\",\"deep_screen\":true}' | jq '{sources: [.matches[].match_sources[].source] | unique, fallback: .deep_screen_fallback_reason}'"],"expect":"exactly one of three honest outcomes: match sources include \"deep_screen\"/\"ofac_ai_variation\" (expansion ran), OR deep_screen_fallback_reason is set (transient AI failure, standard recall delivered and disclosed), OR the call itself fails 503 deep_screen_unavailable (deployment has no AI backend). A silent fall-through — no deep sources, no fallback reason, HTTP 200 — is the defect and can no longer occur"}},{"date":"2026-07-29","category":"added","summary":"The audit trail now covers every screening channel: batch items and portfolio-monitoring evaluations persist full evidence records, and /v1/export/bulk gains name= and channel= filters for entity-centric retrieval","details":"Previously only POST /v1/screen decisions were written to the immutable audit store — batch items and monitoring evaluations were invisible to GET /v1/export, and no entity-centric retrieval existed (an examiner's \"show me all screening activity for this customer\" could not be answered). Now: every batch item persists a screening_result evidence record including the list versions it screened against (batch previously captured none); every portfolio-monitoring evaluation persists a per-client evidence record in the same transaction as its alerts, and monitoring alerts link to their evidence via screening_result_id; export records carry two new fields — channel (api|batch|monitoring) and screened_name — and GET /v1/export/bulk accepts ?name= (case-insensitive, cross-channel entity filter) and ?channel=. The request/response payloads inside export records are the verbatim audit payloads; their shape is channel-dependent and documented on ExportRecord (api-channel records are byte-identical to before). POST /v1/screen is now fail-closed on audit persistence: a screening that cannot be recorded returns an error instead of an unrecorded success. Additive — no version change.","endpoint":"/v1/export/bulk","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\"}' \u003e/dev/null","curl -s -X POST \"$BASE/v1/batch\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"names\":[{\"full_name\":\"Vladimir Putin\"}]}' \u003e/dev/null; sleep 3","curl -s \"$BASE/v1/export/bulk?from=$(date -v-1d +%F 2\u003e/dev/null || date -d yesterday +%F)\u0026to=$(date +%F)\u0026name=Vladimir%20Putin\" -H \"X-API-Key: $KEY\" | jq '[.data[].channel] | unique'"],"expect":"the export returns records for the same name from more than one channel (at least [\"api\",\"batch\"]); each record carries channel, screened_name, and its verbatim request/response payloads incl. list_versions"}},{"date":"2026-07-29","category":"fixed","summary":"DOB/country mismatch penalties now apply to matches from all 10 list sources — France, Belgium, Netherlands, UN, Canada, and Australia matches were previously exempt","details":"Secondary-attribute scoring (OFAC FAQ 5 Step 4: penalize a name match whose date_of_birth or country contradicts the list record) only fetched attribute data for OFAC, UK, and EU matches. Matches from the other six lists silently skipped the check, so a qualified request kept the full name-match score even when the supplied DOB or country disproved the match — inflated false positives on those lists. All prefixed list sources now route to source-specific attribute fetchers (with per-list date-format parsing, incl. Dutch dd-mm-yyyy and UN year-only records). Scores for mismatched-attribute matches on the six lists will DROP after this date; matches without stored attribute data remain unpenalized (absence of data never proves a mismatch). Additive — no version change.","endpoint":"/v1/screen","verification":{"steps":["# Pick any match from a non-OFAC/UK/EU list, then screen it with a contradicting DOB:","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"\u003cname from a france/un/canada match\u003e\",\"lists\":[\"france\"],\"date_of_birth\":\"1901-01-01\"}' | jq '.matches[0] | {score, dismissal_signals}'"],"expect":"when the list record carries a DOB, the score is reduced and dismissal_signals contains a date_of_birth entry (previously: full score, empty signals). If the record has no stored DOB, score is unchanged — absence of data is not treated as a mismatch"}},{"date":"2026-07-28","category":"fixed","summary":"Portfolio monitoring matches at the same sensitivity as /v1/screen, deduplicates alerts for API-key-only accounts, and processes list updates in minutes regardless of portfolio size","details":"Three monitoring fixes ship together. (1) Sensitivity parity: reverse screening (list update → your portfolio) previously matched at a stricter sensitivity than /v1/screen — a name pair the screening endpoint flags could be silently missed by monitoring. Both directions now match at identical sensitivity. Tenants with a low configured alert_threshold may see additional monitoring alerts after this date; those represent matches that were always intended to alert. (2) Dedup: monitoring alerts for accounts without a tenant workspace were never deduplicated — the same portfolio entity + sanctioned person could alert repeatedly across list imports. Deduplication now applies to all accounts, backed by a database uniqueness constraint. (3) Scale: reverse screening moved from the list-watcher hot path to a resumable background job with a trigram-indexed match probe — list updates are now screened against portfolios of any size in minutes, and an interrupted run resumes instead of restarting. Additive — no version change.","endpoint":"/v1/alerts","verification":{"steps":["# After the next sanctions list update that matches your portfolio:","curl -s \"$BASE/v1/alerts?status=new\u0026limit=100\" -H \"X-API-Key: $KEY\" | jq '[.data[] | select(.source==\"monitoring\")] | group_by(.portfolio_entity_id, .sdn_first_name, .sdn_last_name) | map(length) | max'"],"expect":"1 (or null if no monitoring alerts yet) — no open duplicate alerts exist for the same portfolio entity + sanctioned person; re-imports of the same list data do not re-alert"}},{"date":"2026-07-09","category":"fixed","summary":"/v1/batch now applies the same DOB/country/entity_type and watchlist scoring as /v1/screen — a name scores identically on both endpoints","details":"Completes the screen/batch scoring-parity work from #60 for qualified requests (#71). The #57/#60 fix unified the single-token partial-name penalty across both paths, but two other score-mutating steps still ran only on /v1/screen: the secondary-attribute penalties (screen.AdjustScores — DOB/country/entity_type, OFAC FAQ 5 Steps 2 \u0026 4) and the tenant watchlist. A batch item carrying a DOB or country qualifier therefore scored higher than the same name+qualifier via /v1/screen — the higher-stakes real-time path was the weaker one. Both endpoints now run one shared scoring pipeline (screen.FinalizeScores: watchlist → secondary-attribute penalties → single-token penalty), so a name scores identically whichever endpoint is called. /v1/batch names[] additionally accept entity_type, matching /v1/screen; an omitted entity_type never penalizes. Additive — no version change.","endpoint":"/v1/batch","verification":{"steps":["S=$(curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\",\"country\":\"US\"}' | jq '.matches[0].score')","ID=$(curl -s -X POST \"$BASE/v1/batch\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"names\":[{\"full_name\":\"Vladimir Putin\",\"country\":\"US\"}]}' | jq -r '.id')","sleep 2; B=$(curl -s \"$BASE/v1/batch?id=$ID\" -H \"X-API-Key: $KEY\" | jq '.items[0].highest_score'); echo \"screen=$S batch=$B\""],"expect":"the /v1/screen top score equals the /v1/batch highest_score for the qualified request (both reflect the country_mismatch penalty — screen == batch, not batch \u003e screen)","audit":"GET /v1/export?trace_id=\u003ctrace_id\u003e replays the /v1/screen result with its list_versions","regression":"tests/screen_batch_parity_test.go::TestScreenBatchQualifiedParity"}},{"date":"2026-07-06","category":"added","summary":"Weak-vs-strong AKA quality is now surfaced: match_sources[].alias_quality and matches[].weak_alias_only","details":"OFAC flags certain aliases as weak 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). Previously a hit driven solely by a weak alias was indistinguishable from a strong-name hit. Each match_source that fired via an OFAC AKA now carries alias_quality (\"weak\" or \"strong\"; empty for primary-name, variation, ID, and non-OFAC pathways), and each match carries weak_alias_only — true when every pathway corroborating the match is a weak alias, so callers can deprioritize or auto-dismiss those hits. Additive — no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"THE CHESS PLAYER\"}' | jq '.matches[] | select(.uid==\"4107\") | {weak_alias_only, sources:[.match_sources[]|{source,alias_quality}]}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\"}' | jq '.matches[0].weak_alias_only'"],"expect":"the weak-AKA hit (uid 4107) has weak_alias_only=true with an ofac_aka source of alias_quality \"weak\"; the Putin top match (primary name / strong alias) has no weak_alias_only","audit":"GET /v1/export?trace_id=\u003ctrace_id\u003e replays the result with its list_versions","regression":"tests/api_test.go::TestScreenAliasQuality; internal/nobleclient/methods_test.go::TestScreenDecodesRichMatchFields"}},{"date":"2026-07-06","category":"added","summary":"matches[].prohibition_type distinguishes a full SDN block from a CMIC investment ban, SSI sectoral restriction, or reject","details":"OFAC hits do not all carry the same action, but they were framed identically. An SDN (or 50%-Rule) listing is a blocking designation — freeze the property and file a blocking report — but a CMIC listing (E.O. 13959) is a securities investment ban, an SSI listing (E.O. 13662) is a sectoral dealing restriction, and FSE/NS-PLC are rejects; none of those are blocked. Each OFAC match now carries prohibition_type — one of blocking, cmic_investment, ssi_sectoral, fse_reject, plc_reject, capta — derived from its programs, so a caller applies the correct action instead of over-blocking a CMIC or SSI entity. Blocking dominates a mixed listing (an entity on both SDN and a sectoral program is blocking). Non-OFAC matches omit the field, since the taxonomy is OFAC-specific. Additive — no version change. (The SSI Directive number is a planned follow-up; it is not in the current ingest.)","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\"}' | jq '[.matches[] | {source, prohibition_type}]'"],"expect":"every OFAC match carries prohibition_type (an SDN listing is \"blocking\"); matches from non-OFAC lists omit prohibition_type","audit":"GET /v1/export?trace_id=\u003ctrace_id\u003e replays the result with its list_versions","regression":"api/v1/prohibition_test.go::TestProhibitionType; tests/api_test.go::TestScreenProhibitionType"}},{"date":"2026-07-06","category":"fixed","summary":"Screen a sanctioned vessel by its IMO number; identifier matching is now punctuation-insensitive on both sides","details":"Identifier screening normalized only the input (stripping spaces, dashes, dots, slashes) but compared it against the raw stored value, so any identifier published with punctuation could never match. The most visible casualty was vessel IMO numbers, which OFAC publishes as \"IMO 9102198\": a vessel was unscreenable by its permanent identifier. Stored identifiers are now normalized identically to the input (backed by new functional indexes across every list's ID table, so matching stays an index lookup), and a vessel matches whether screened as \"IMO 9102198\" or the bare \"9102198\". This also fixes the broader class of punctuated identifiers (hyphenated tax IDs, spaced registration numbers) across all lists. Additive — no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"ids\":[{\"number\":\"IMO 9102198\"}]}' | jq '{verdict:.summary.verdict, top:.matches[0].last_name, type:.matches[0].sdn_type, score:.matches[0].score}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"ids\":[{\"number\":\"9102198\"}]}' | jq '.matches[0].last_name'"],"expect":"both the \"IMO 9102198\" and bare \"9102198\" forms return the vessel CLIPPER (sdn_type \"Vessel\") at score 100 with an alert, no name supplied","audit":"GET /v1/export?trace_id=\u003ctrace_id\u003e replays each screen with its list_versions","regression":"tests/api_test.go::TestScreenVesselByIMO"}},{"date":"2026-07-06","category":"added","summary":"Screen a digital-currency address or any identifier on its own — name is no longer mandatory when an identifier is supplied","details":"A wallet address, passport, tax ID, or other identifier can now be screened with no name: send it in ids (e.g. {\"ids\":[{\"number\":\"0x…\"}]}) and the listed entity is returned by exact ID match. A type is optional — when omitted the number is matched against every identifier category. An address pasted into the name field is now also caught: a whitespace-free alphanumeric token is additionally exact-matched as an identifier, so a listed wallet address returns its SDN instead of only sub-threshold fuzzy noise (matching OFAC's exact-match discipline for identifiers, FAQ 594). A request with neither a name nor an identifier is rejected with 400 query_required. Additive — no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"ids\":[{\"number\":\"0x7FF9cFad3877F21d41Da833E2F775dB0569eE3D9\"}]}' | jq '{verdict:.summary.verdict, top:.matches[0].last_name, score:.matches[0].score}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"0x7FF9cFad3877F21d41Da833E2F775dB0569eE3D9\"}' | jq '.matches[0].score'","curl -s -o /dev/null -w '%{http_code}\\n' -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"limit\":5}'"],"expect":"the address (via ids, and pasted into name) returns GARANTEX EUROPE OU at score 100 with an alert; a request with neither name nor identifier returns HTTP 400 code query_required","audit":"GET /v1/export?trace_id=\u003ctrace_id\u003e replays each screen with its list_versions","regression":"tests/api_test.go::TestScreenIdentifierOnly"}},{"date":"2026-07-04","category":"fixed","summary":"Screening rank is now independent of limit; whitespace-only names are rejected","details":"Two fixes (#58). (1) The limit parameter caps how many matches are returned but must not change which match ranks #1. The internal top-entities selection truncated equal-scored rows in arbitrary order, so limit=1 could surface a different top match than limit=10 for the same query — a caller using limit=1 as \"the single best match\" could clear or dismiss against the wrong record. A uid tiebreak now makes the ranking a total order, so limit=N returns exactly the top N of one stable ordering (score descending, then uid) and the #1 match is identical for every limit. (2) A whitespace-only name (e.g. \"   \") was accepted and screened; it is now trimmed before validation and rejected with 400 name_required like an empty name. Additive — no version change.","endpoint":"/v1/screen","verification":{"steps":["A=$(curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Mohammed Ali\",\"limit\":1}' | jq -r '.matches[0].uid')","B=$(curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Mohammed Ali\",\"limit\":10}' | jq -r '.matches[0].uid'); [ \"$A\" = \"$B\" ] \u0026\u0026 echo RANK_STABLE","curl -s -o /dev/null -w '%{http_code}\\n' -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"   \"}'"],"expect":"the limit=1 and limit=10 top-match uid are equal (RANK_STABLE); the whitespace-only screen returns HTTP 400 (code query_required once identifier-only screening shipped, name_required before)","audit":"GET /v1/export?trace_id=\u003ctrace_id\u003e replays each screen with its list_versions","regression":"tests/screen_test.go::TestScreenLimitRankingStable; tests/api_test.go::TestScreenWhitespaceNameRejected"}},{"date":"2026-07-04","category":"fixed","summary":"entity_type is now a safe disambiguation hint — supplying the correct type never suppresses an alert, and the single-token penalty no longer misfires on organizations","details":"Two entity-recall bugs (#59). (1) The entity_type_mismatch penalty compared the caller's canonical entity_type against each list's raw, incompatible subject-type vocabulary (OFAC \"Entity\", France \"Personne morale\", EU \"enterprise\", UN \"entity\"), so supplying the documented entity_type:\"entity\" for an organization such as SBERBANK triggered a spurious penalty large enough to drop a confirmed match below the alert threshold. Both sides are now normalized to a canonical vocabulary before comparison; only a genuine, known-category mismatch is penalized, and an unknown or unmapped source type is never penalized — guaranteeing that supplying the correct entity_type can never lower a score relative to omitting it. (2) The single-token partial-name penalty (OFAC FAQ 5 Step 3, a person-name control) was applied to organizations, which are routinely referenced by one distinctive token (\"Sberbank\"); it now skips organizations, vessels, and aircraft. Additive scoring correction — no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Sberbank\"}' | jq '{score:.matches[0].score, alert:.alert_id}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Sberbank\",\"entity_type\":\"entity\"}' | jq '{score:.matches[0].score, alert:.alert_id}'"],"expect":"both calls return matches[0].score \u003e= 80 with an alert; the entity_type=entity score is \u003e= the no-hint score (never lower)","audit":"GET /v1/export?trace_id=\u003ctrace_id\u003e replays each result with the list_versions that produced it","regression":"internal/screen/attributes_test.go::TestAdjustScoresEntityTypeNormalization + ::TestCanonicalEntityType; internal/screen/dismissal_signals_test.go::TestApplyNameTokenSignalSkipsOrganizations; tests/api_test.go::TestScreenEntityTypeRecall"}},{"date":"2026-07-04","category":"fixed","summary":"lists_screened now reflects the lists actually loaded and queried, not a static supported-list array","details":"The lists_screened field on every /v1/screen response was a static array of all supported list identifiers, so it could report coverage that is not ingested (e.g. Canada, Australia) and contradict the same response's list_versions. It is now derived from list_versions — the set of lists with a publisher snapshot actually on file — so lists_screened is always a subset of list_versions, and a list appears only once it is genuinely loaded and queryable. Additive — no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zxqwerty Nonexistent Person\"}' | jq '{screened:.lists_screened, versions:[.list_versions[].list]}'"],"expect":"lists_screened has exactly the same members as .list_versions[].list; it never contains a list absent from list_versions","audit":"GET /v1/sanctions-lists returns the authoritative loaded set — it must match lists_screened","regression":"tests/api_test.go::TestScreenListsScreenedMatchesLoaded"}},{"date":"2026-07-04","category":"fixed","summary":"Corrected the published /llms.txt evaluation script — step 3 no longer promises a verdict the API does not return","details":"The \"Evaluate Noble in 60 seconds\" script in /llms.txt promised that screening \"Vladimir Petrov\" returns summary.verdict: no_significant_match as proof of false-positive discipline; in reality that common name can reach a match and fire an alert — the opposite of the documented result. Step 3 now demonstrates discipline with a check that is true by construction: re-screen a known strong match with a contradicting attribute ({\"name\":\"Vladimir Putin\",\"country\":\"US\"}) and observe the score drop with a structured country_mismatch dismissal_signal that explains the reduction. Documentation-only correction — no behavior change.","endpoint":"/llms.txt","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\",\"country\":\"US\"}' | jq '{score:.matches[0].score, signals:.matches[0].dismissal_signals}'"],"expect":"matches[0].score \u003c 100 and dismissal_signals contains {reason_code:\"country_mismatch\", impact_on_score:-30}; the /llms.txt step-3 example now matches the observed behavior","audit":"GET /llms.txt step 3 describes exactly this call and outcome","regression":"internal/screen/dismissal_signals_test.go::TestCompareCountryStructured"}},{"date":"2026-07-04","category":"fixed","summary":"Name-variant recall: hyphen/space/transliteration variants no longer drop a confirmed match below the alert threshold; /v1/screen and /v1/batch now score identically","details":"The OFAC FAQ 5 Step 3 single-token partial-name penalty was misfiring on trivial orthographic variants because it compared name tokens by exact string equality. 'Kim Jong-un' (hyphen), 'Bashar Al Assad' (spacing), 'Qassem Soleimani' (doubled consonant), and 'Ramzan Kadirov' (i/y transliteration) each counted as a single-token match and had their score halved from 100 to 50 — below the default 80 alert threshold, producing no alert on a confirmed SDN (a false negative). The token check now splits on hyphens and uses the same Soundex equivalence as the core trigram+phonetic engine, so a variant the engine already scores as a full match is no longer penalized. Separately, the penalty ran only on the real-time /v1/screen path and not on /v1/batch, so the two endpoints returned different scores for the same name; both now run the identical scoring pipeline. Genuine single-token collisions (a bare surname such as 'Putin' against 'Vladimir Putin') are still penalized. Additive scoring correction — no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Kim Jong-un\"}' | jq '{verdict:.summary.verdict, score:.matches[0].score, alert:.alert_id}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Qassem Soleimani\"}' | jq '{score:.matches[0].score, alert:.alert_id}'","# parity: the single-name batch highest_score must equal the /v1/screen score for the same name"],"expect":"matches[0].score \u003e= 80, summary.verdict == \"confirmed_match\", and alert_id != null for each variant; and screen(name).matches[0].score == batch([name]).items[0].highest_score","audit":"GET /v1/export?trace_id=\u003ctrace_id\u003e replays the same result with the list_versions that produced it","regression":"internal/screen/dismissal_signals_test.go::TestApplyNameTokenSignal; tests/screen_batch_parity_test.go::TestScreenBatchVariantParity"}},{"date":"2026-06-17","category":"added","summary":"Confidential Portfolio (BYOK) — encrypt portfolio PII at rest under a customer-controlled key","details":"New Enterprise add-on. When enabled, a client's portfolio PII (name, DOB, country, external ref) and the matching monitoring-alert names are stored encrypted at rest under a per-customer KMS-wrapped key; the server decrypts in-memory only to run portfolio monitoring and to serve the owner's authenticated reads. Manage the key at /v1/portfolio/key: GET returns status (active/revoked/none), POST provisions, DELETE?confirm=\u003cclient_id\u003e revokes and crypto-shreds (irreversible — destroys the KMS key so the data is permanently unreadable). GET /v1/account now reports features.confidential_portfolio. This is encryption at rest with a customer-controlled key, not zero-knowledge: monitoring keeps running. Substring name search over encrypted entities is unavailable (exact dedup still works via a blind index). Additive — no version change.","endpoint":"/v1/portfolio/key"},{"date":"2026-06-07","category":"added","summary":"Stats companions for portfolio, webhooks, and case activity — uniform collection counts","details":"Counts are now a first-class read on every collection, not just alerts. New GET /v1/portfolio/stats (total + by_score_band, honors q/min_score/max_score), GET /v1/webhooks/stats (total, by_status, limit, remaining), and GET /v1/alerts/activity/stats (total + by_action, honors the same alert_id/actor/action/from/to filters as the list) join the existing GET /v1/alerts/stats. The convention is uniform: a collection at /v1/\u003cname\u003e exposes its counts at /v1/\u003cname\u003e/stats, and a stats endpoint honors the same filters as its list sibling. This recovers the row counts dropped from list envelopes in the 2026-06-01 cursor-pagination change, without re-adding O(n) total to page fetches. Additive — no version change.","endpoint":"/v1"},{"date":"2026-06-06","category":"added","summary":"Noble-Version header for API version pinning","details":"Clients may pin a dated API version with the Noble-Version request header (e.g., Noble-Version: 2026-06-01); the resolved version is echoed in the Noble-Version response header. No header pins to the latest version, so existing integrations are unaffected; an unrecognized version is rejected with 400 invalid_version. This entry is additive and therefore does not itself mint a new version — the latest version remains 2026-06-01 (cursor pagination), the most recent breaking change.","endpoint":"/v1"},{"date":"2026-06-01","category":"changed","summary":"Uniform cursor pagination across all list endpoints — {data, has_more}, ?after=\u0026limit=","details":"Every collection endpoint (alerts, portfolio, watchlist, export/bulk, alerts/activity, webhooks) now returns the same envelope: {\"data\": [...], \"has_more\": bool}. The array key is always \"data\". Pagination is keyset cursor only — ?after=\u003cid\u003e\u0026limit=\u003cn\u003e, ordered by id ascending; pass the id of the last item in data as ?after= for the next page. This is a breaking change to the wire shape: the per-endpoint keys (alerts, entities, entries, records, webhooks), offset/page params, and total counts are removed. Use GET /v1/alerts/stats for bounded counts; use the async export job for full extraction. /v1/webhooks returns the same envelope but is not paginated (capped per tenant).","endpoint":"/v1/alerts","breaking":true},{"date":"2026-05-27","category":"fixed","summary":"summary.fallback_reason guaranteed on every non-LLM deep_summary response","details":"Hardened the deep_summary path with a deferred invariant: when deep_summary=true and the response carries deterministic prose (LLM unavailable, marshalling error, Gemini failure, or empty LLM payload), fallback_reason is now guaranteed to be populated with llm_unavailable or llm_failure. Closes a gap where callers requesting a paid feature could receive deterministic prose with no signal that the LLM did not deliver. Empty LLM text or model from the Gemini path is now treated as llm_failure rather than passing through.","endpoint":"/v1/screen"},{"date":"2026-05-26","category":"added","summary":"match_sources[].matched_text and matched_length surface which string fired each pathway","details":"Every match-source row now carries the actual source-list string that produced its trigram score — the primary name, alias, or variation text the comparison hit. matched_length is the rune count, pre-computed for server-side aggregation. Lets consumers distinguish 'matched primary name Vladimir Putin' from 'matched 4-character alias JOHN', and is the foundation for length- and quality-aware scoring follow-ups. Empty when not applicable (e.g., exact ID matches).","endpoint":"/v1/screen"},{"date":"2026-05-26","category":"fixed","summary":"Filter OFAC metadata (Gender, Secondary sanctions risk:) out of top_match.ids and matches[].ids","details":"OFAC publishes some non-identifier features (gender, sanctions-program annotations) using the same \u003cID\u003e element as real identifiers. These were leaking into the ids array on every match and misleading compliance officers and AI agents. Only real identifier types (Passport, Tax ID, SWIFT/BIC, crypto wallet addresses, etc.) now surface; the rest are filtered at the API boundary.","endpoint":"/v1/screen"},{"date":"2026-05-26","category":"added","summary":"summary.fallback_reason surfaces when deep_summary silently falls back to deterministic","details":"When deep_summary=true is requested but the LLM path doesn't execute (server not configured, Gemini call failed/timeout), the response now carries fallback_reason (llm_unavailable | llm_failure) so the caller can tell that a paid feature did not deliver. Absent when deep_summary was not requested or when the LLM produced the prose successfully.","endpoint":"/v1/screen"},{"date":"2026-05-26","category":"added","summary":"Structured dismissal_signals on every match (reason codes, severity, score impact)","details":"Each penalty applied during attribute comparison now emits a structured signal alongside the legacy free-text reason. Stable reason codes enable filtering, aggregation, and audit-defensible dismissal records. The legacy dismissal_reasons field is preserved byte-for-byte during a 90-day deprecation window; both fields are guaranteed to have equal length and ordered correspondence.","endpoint":"/v1/screen"},{"date":"2026-05-26","category":"deprecated","summary":"dismissal_reasons (free-text array) — use dismissal_signals instead","details":"Free-text reasons cannot be filtered or aggregated. The structured dismissal_signals field carries the same information as reason codes, severity, and per-signal score impact. Removal scheduled with the next major API version; both fields remain populated until then.","endpoint":"/v1/screen"},{"date":"2026-05-26","category":"changed","summary":"Enforce OFAC FAQ 5 Step 3: single-token name match against multi-token sanctioned name is penalized","details":"Matches where only a single token of the request name overlaps with a multi-token sanctioned name (e.g., 'Putin' alone against 'Vladimir Putin') now receive a substantial score reduction and a partial_name_match_single_token dismissal signal. This codifies OFAC's 'just one of two or more names matching, i.e. just the last name' not-a-valid-match condition.","endpoint":"/v1/screen"},{"date":"2026-05-25","category":"added","summary":"GET /v1/changelog returns machine-readable API release notes","endpoint":"/v1/changelog"},{"date":"2026-05-25","category":"changed","summary":"POST /v1/screen accepts multiple ids; matches include matched_via","details":"Send an ids array (each with id_type + value). When a match is found by ID, the match object carries a matched_via field naming which ID hit.","endpoint":"/v1/screen"},{"date":"2026-05-25","category":"added","summary":"summary.top_match carries OFAC 5-step PII (DOB, addresses, IDs)","endpoint":"/v1/screen"},{"date":"2026-05-24","category":"added","summary":"Opt-in executive summary on screening responses (summary field)","endpoint":"/v1/screen"},{"date":"2026-05-15","category":"added","summary":"Idempotency-Key header supported on all state-mutating POST endpoints","details":"Retries with the same key within 24h return the cached response and produce zero side effects."},{"date":"2026-05-13","category":"fixed","summary":"meta.disclaimer and structured errors enforced across all v1 endpoints"},{"date":"2026-05-12","category":"added","summary":"Invite-only signup available via /v1/admin/invites"}],"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."}}
