{"entries":[{"date":"2026-09-10","category":"fixed","summary":"Canada (SEMA) screening is current again — the publisher renamed every field in its feed, which stopped imports silently on 2026-08-24 while screening continued against the last good snapshot","details":"Global Affairs Canada republished the SEMA consolidated list with bilingual field names. Noble read the feed by the previous names, so every record arrived with an empty identity. The import refused the file rather than loading 5,698 unidentifiable records — the correct outcome, and screening continued uninterrupted against the 2026-08-19 snapshot, so no screening ran against corrupt data. What was wrong is that the refusal was quiet: GET /v1/lists reported canada as stale and GET /status reported degraded, exactly as designed, but neither distinguished a publisher format change from any other cause, and the condition persisted for seventeen days. Canada now imports on the normal polling cadence, and any future rename of a field fails the build rather than reaching production. Customers screening against canada should expect designations and delistings published after 2026-08-19 to appear. No request or response shape changed, so no version is minted.","endpoint":"/v1/lists","verification":{"steps":["curl -s https://api.noblesight.io/v1/lists | jq -c '.lists[] | select(.list==\"canada\") | {status, publish_date, record_count, last_checked, last_import}'","curl -s https://api.noblesight.io/status | jq -c '{status, lists: .components.lists.status}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Alexander Lukashenko\",\"lists\":[\"canada\"]}' | jq -c '{screened: .lists_screened, versions: .list_versions, count: .match_count}'"],"expect":"canada reports status \"ok\" with last_checked inside the last polling interval and a publish_date on or after 2026-09-10; /status reports \"operational\" with components.lists \"ok\"; and a canada-scoped screening returns list_versions naming canada at the new publish id","audit":"each import is recorded with its publisher snapshot id and record count, and every screening persists the list_versions it ran against, so a Canada screening from any date can be replayed against the exact snapshot that produced it","regression":"TestParseXMLBindsEveryElement asserts every SEMA field reaches its parsed value and fails on a rename; TestParseXMLLiveFixtureVocabulary checks the published feed still uses those names; TestParseXMLRejectsDuplicateSids locks the fail-closed guard that refused the unreadable file"}},{"date":"2026-09-10","category":"fixed","summary":"The public site now reports each sanctions list's real ingestion status instead of a fixed label, and the list endpoint is named correctly for AI agents","details":"The coverage grid on the public site labelled every list \"Live\" as static text, so it made a freshness claim independent of the API and could not follow a list into a stale state. Each badge is now resolved from GET /v1/lists when the page loads and reads Live or Stale to match, with the last import and last check times available on hover. With scripting unavailable the badges assert nothing rather than defaulting to Live, and the grid links to /v1/lists and /status so the claim is checkable at its source. A stale list is still screened, against its last imported snapshot, and /status continues to report degraded while any list is stale — that behaviour is unchanged; only the public claim now matches it. Separately, the machine-readable guide at /llms.txt directed agents to GET /v1/sanctions-lists, which is not a route and returns 404; it now names GET /v1/lists and points at the per-list status and last-checked fields as the authority on freshness. No API request or response shape changed.","endpoint":"/v1/lists","verification":{"steps":["curl -s https://api.noblesight.io/v1/lists | jq '[.lists[] | {list, status}]'","curl -s -o /dev/null -w '%{http_code}\\n' https://api.noblesight.io/v1/sanctions-lists   # 404 — the name corrected in /llms.txt","Open https://noblesight.io/#coverage and compare each card's badge against step 1."],"expect":"every card reads Live for a list whose status is \"ok\" and Stale for any list whose status is not \"ok\", matching step 1 exactly; step 2 returns 404, and /llms.txt no longer cites that path","audit":"the page reads the same public, unauthenticated GET /v1/lists that step 1 calls, so the badge and the API cannot disagree; each badge also exposes the list's last_import and last_checked on hover for comparison against the same fields in the response","regression":"TestMarketingBadgesDoNotHardcodeLive, TestLLMsTxtNamesRealEndpoints"}},{"date":"2026-09-09","category":"fixed","summary":"A service-side failure is no longer reported as an authentication, permission, or not-found error — those now mean what they say, and a transient fault returns 5xx so it can be retried","details":"Several endpoints treated any failure during a lookup as a statement about the request: a key that could not be verified was reported as an invalid API key, an account whose tenant could not be read was reported as not provisioned, and a record that could not be fetched was reported as not found. All three are indistinguishable from the real thing, and all three invite a damaging response — rotating a credential that was never bad, opening a support ticket about an account that is fine, re-enrolling a monitored name that is already enrolled, or abandoning a batch job that is still running. Fault attribution is now a contract across the API: a 4xx describes the request or the account and will still be true on retry, while a service-side failure always returns 5xx with type api_error. A genuinely unknown key still returns 401, an account without a tenant still returns 403, and a record that genuinely does not exist still returns 404 — the change narrows those answers to the cases where they are true rather than removing them. One further case: a batch whose results could not be read now returns an error instead of a completed job with an empty result list. Clients may treat 4xx as terminal and 5xx as retryable. No request or response shape changed.","endpoint":"/v1/export","verification":{"steps":["curl -s -o /dev/null -w '%{http_code}\\n' -X GET 'https://api.noblesight.io/v1/export?trace_id=definitely-not-a-real-trace-id' -H \"X-API-Key: $KEY\"","curl -s -X GET 'https://api.noblesight.io/v1/export?trace_id=definitely-not-a-real-trace-id' -H \"X-API-Key: $KEY\" | jq -c '{type, code}'","curl -s -o /dev/null -w '%{http_code}\\n' -X GET https://api.noblesight.io/v1/webhooks -H 'X-API-Key: noble_live_0000000000000000000000000000000000'"],"expect":"the first two calls return 404 with {\"type\":\"not_found_error\",\"code\":\"not_found\"}, which now means the record is genuinely absent rather than that the lookup failed; the third returns 401 with code \"api_key_invalid\". Any 4xx from this API is safe to treat as terminal — retrying it will produce the same answer — and any transient service-side failure arrives as 5xx with type \"api_error\" instead","audit":"every response carries a request_id, and a 5xx is logged server-side against that id with the underlying cause, so an error a customer saw can be attributed to a service fault or a genuine business answer after the fact","regression":"TestAPIKeyAuthDBFailureIsNot401, TestRequireTenantSplitsFaultFromProvisioning, TestLookupFailureIsNot404, TestAbsentRecordIsStill404, TestDBFailureIsNotErrNotFound"}},{"date":"2026-09-08","category":"fixed","summary":"Screening no longer reports a shortened lists_screened when a sanctions list's version could not be confirmed — the request now fails with 503 coverage_unavailable instead","details":"lists_screened and list_versions report which sanctions lists a screening actually ran against, and examiners read them as the proof of coverage. Previously, if one list's version could not be confirmed while the request was in flight, that list was quietly dropped from both fields and the response still returned 200 with a clean verdict — a narrower screen than was delivered, indistinguishable on the wire from a legitimately narrow one, with no error field anywhere to notice. The screening request now fails closed with 503 and error code coverage_unavailable rather than returning a partial answer as a complete one; the same applies to a point-in-time replay, and a batch job fails rather than persisting evidence rows that understate its coverage. A list that has no published snapshot at all is still omitted normally and is not an error — that list was genuinely not screenable, and lists_screened continues to widen on its own as lists come online. Retry a coverage_unavailable response; it is transient. No response shape changed, so no version is minted.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\",\"lists\":[\"ofac\",\"uk\",\"eu\"]}' -w '\\n%{http_code}\\n' | jq -c '{screened: .lists_screened, versions: [.list_versions[].list], code: .error.code}'","Repeat the call on a schedule and assert the invariant on every response: either the HTTP status is 200 and lists_screened contains every list you named, or the status is non-2xx. A 200 carrying only some of the lists you asked for is a failure."],"expect":"200 with lists_screened == [\"ofac\",\"uk\",\"eu\"] and list_versions carrying one entry per list; or, when a list version cannot be confirmed, 503 with error.code == \"coverage_unavailable\" and no matches or verdict in the body","audit":"every persisted screening carries the list_versions it ran against, so the stored record and the response agree on coverage by construction — a screening is never recorded against list versions it did not confirm","regression":"TestListVersionsFailsClosedOnLookupError, TestListVersionsSkipsMissingSnapshot, TestGetListVersionsAsOfPropagatesError"}},{"date":"2026-08-31","category":"fixed","summary":"Ownership determinations are no longer replaced by a smaller set when the underlying ownership data is incomplete — the previous determinations stay in service instead","details":"The ownership determination set is rebuilt from scratch on a schedule. If the underlying ownership data was incomplete at the moment of a rebuild, the rebuild would previously still be published, replacing a larger set of determinations with a smaller one — so a company that returned blocked could quietly begin returning unknown, with no error anywhere to notice. The rebuild now compares itself against the last published set and refuses to publish when the number of determinations collapses, leaving the previous determinations serving and recording the refusal. The conservative direction is deliberate: continuing to serve slightly stale determinations can only over-report, which surfaces as a reviewable alert, while publishing an incomplete set under-reports invisibly. A genuine large reduction can still be published deliberately, and that decision is recorded. No request or response shape changed.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Advanced Mining Works\"}' | jq '{status: .ownership.status, pct: .ownership.cumulative_percent, as_of: .ownership.as_of}'","Repeat the identical call after the next scheduled rebuild has run (at least 24h later) and compare the two responses field by field."],"expect":"ownership.status is \"blocked\" on both calls with the same cumulative_percent; the status never changes to \"unknown\" or \"clean\" unless a sanctions list itself changed","audit":"the determination carries as_of and source_publish_ids pinning the data snapshot it was computed from, so a change in answer is always traceable to a change in source data","regression":"TestRecomputeShrinkGuard, TestShrankTooFar"}},{"date":"2026-08-31","category":"fixed","summary":"Ownership determinations now cover companies identified only in a sanctions list's narrative text — screening such a company returns its determination and evidence chain instead of unknown","details":"Some companies are never designated in their own right and appear only inside another entry's narrative description, which is where an ownership relationship to a designated person is often stated. Those companies are part of the ownership picture, but a portion of them could not be reached by name at screening time, so screening them returned unknown even though the relationship had been recorded. They are now reachable, and screening returns the determination the ownership computation reached for them, with the evidence chain and the source quote behind it. Two changes produce this: the recorded ownership assertions can now be restated into the ownership graph directly, so a determination is never stranded by an interrupted batch run; and a batch run is no longer treated as covering a document until that document's results are durable, so an interruption is retried rather than silently skipped. Expect some companies that previously returned unknown to return review or blocked. Response shape is unchanged and all four status values remain as documented, so no version is minted.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Nomenko\"}' | jq '{status: .ownership.status, chains: (.ownership.chains | length)}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Nomenko\"}' | jq '.ownership.chains[0].hops[] | select(.is_sanctioned) | .name'"],"expect":"ownership.status is \"review\" (not \"unknown\"), with at least one evidence chain; the second call names the designated owner the chain reaches","audit":"each chain hop carries the entity it passed through and whether that entity is itself designated, and the determination carries as_of and source_publish_ids pinning the computation snapshot","regression":"TestReplayRepairsUnreachableDeterminations, TestExtractionWatermarkRequiresDurability"}},{"date":"2026-08-31","category":"fixed","summary":"Ownership determinations now reach names they previously missed, and clean is no longer returned when the ownership computation did not actually evaluate the subject — those cases return unknown","details":"One real-world company can appear in the ownership graph under several names, and the determination attaches to only one of them. Screening a different form of the name could therefore return clean while a determination existed under a sibling name. Three corrections: (1) when candidate names tie, the one carrying a determination is now preferred, so the determination is returned instead of being masked; (2) when the subject resolves to an entity the computation does not evaluate — one that is itself designated, and so directly blocked rather than derivatively blocked — the response is unknown rather than clean; (3) when a near-identically named entity carries a determination that the resolved one does not, the result is ambiguous and returns unknown rather than clean. A determination is never transferred onto a name it was not computed for, and a lower-similarity name never outranks a better one. Expect some subjects that previously returned clean to return unknown, and some to return review or blocked with full evidence chains. Response shape is unchanged and all four status values remain as documented, so no version is minted.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Naftiran Intertrade Company SRL\"}' | jq '{status: .ownership.status, pct: .ownership.cumulative_percent}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Advanced Mining Works\"}' | jq '{status: .ownership.status, chains: (.ownership.chains | length)}'"],"expect":"the first no longer reports ownership.status \"clean\" for a subject whose determination exists under a fuller form of the same name; the second returns \"blocked\" with at least one evidence chain and a cumulative_percent at or above 50","audit":"the determination carries as_of and source_publish_ids pinning the computation snapshot, and a blocked result raises an alert retrievable via GET /v1/alerts","regression":"TestOwnershipResolver"}},{"date":"2026-08-31","category":"changed","summary":"Ownership determination semantics clarified: clean means no sanctioned ownership was found within the ownership graph's source coverage — advisory input to an institution's 50 Percent Rule program, not an exhaustive all-clear","details":"The OwnershipMatch.Status contract now states this coverage boundary explicitly, and the public capability description (llms.txt) carries the same limitation language in place of an unqualified capability claim. unknown continues to mean the name is not in the graph at all. No request or response shape changed and no field behavior changed — this aligns the public claims with the contract so both tell one story. Additive documentation correction; no version change.","endpoint":"/v1/screen","verification":{"steps":["curl -s https://api.noblesight.io/llms.txt | grep -A1 'Coverage boundary'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"\u003cany name\u003e\"}' | jq .ownership.status"],"expect":"the llms.txt coverage-boundary bullet contains the limitation sentence (clean = no sanctioned ownership found within the graph's source coverage, advisory not a substitute), and ownership.status returns one of blocked/review/clean/unknown exactly as the contract defines them"}},{"date":"2026-08-29","category":"added","summary":"Team registry and analyst identity: POST/GET/DELETE /v1/team manages the analysts alert assignment validates against, and the new X-Actor header attests which registered analyst performed an alert action — making four-eyes closure of high-score alerts workable for teams sharing one API key","details":"Alert assignment has required the assignee to be a registered active team member, but no endpoint existed to register one — assignment rejected every assignee, and because four-eyes closure of high-score alerts (score 90 and above) requires a second analyst identity that could never be introduced, such alerts could not be closed by anyone once reviewed. The team registry closes both gaps. POST /v1/team registers an analyst {email, role: analyst|admin} (idempotent — re-registering reactivates), GET /v1/team lists the registry, DELETE /v1/team/{email} deactivates without deleting so historical case activity stays resolvable. Any alert-mutating call may now carry an X-Actor header naming a registered active analyst; the action's audit actor is then \"analyst:\u003cemail\u003e\" instead of the key principal \"apikey:\u003cprefix\u003e\", and an unverifiable X-Actor is rejected with 400 invalid_actor rather than recorded. Four-eyes compares actors, so two analysts on one key satisfy it by each passing their own X-Actor. Additive — no version change.","endpoint":"/v1/team","verification":{"steps":["curl -s -X POST \"$BASE/v1/team\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"email\":\"analyst2@bank.example\"}' | jq '{email, role, status}'","curl -s -X POST \"$BASE/v1/alerts/$ALERT_ID/escalate\" -H \"X-API-Key: $KEY\" -H \"X-Actor: analyst1@bank.example\" -d '{}' | jq '.status'","curl -s -X POST \"$BASE/v1/alerts/$ALERT_ID/resolve\" -H \"X-API-Key: $KEY\" -H \"X-Actor: analyst2@bank.example\" -d '{\"status\":\"closed_false_positive\",\"reason\":\"false_positive_name_only\"}' | jq '.status'"],"expect":"registration returns the active member; a high-score alert escalated under one analyst's X-Actor closes under a second analyst's X-Actor — the same analyst attempting closure still receives four_eyes_violation, and an unregistered X-Actor receives 400 invalid_actor","audit":"every alert action records its actor in the immutable case activity as analyst:\u003cemail\u003e (validated against the registry at request time) or apikey:\u003cprefix\u003e, so an examiner can attribute each status change to a specific verified analyst; registrations and deactivations are logged with trace IDs","regression":"TestFourEyesUnblockedByXActor walks the full deadlock scenario end to end — escalate as one analyst, closure blocked for the same analyst, closure succeeds for the second, both identities in the audit trail; TestTeamRegistryAPI locks registration, idempotent reactivation, listing, validation, and deactivation"}},{"date":"2026-08-29","category":"fixed","summary":"Batch screening alerts now reach your alert queue: the alert_id on a batch item is retrievable via GET /v1/alerts/{id} and the alert appears in GET /v1/alerts — previously batch alerts were created outside your tenant's scope and could never be retrieved","details":"Alerts raised by batch screening items were created without the tenant association that every alert surface filters by, so the alert_id a batch item reported referenced an alert that GET /v1/alerts/{id} answered with 404 and GET /v1/alerts never listed — the batch channel was silently disconnected from alert disposition. Batch jobs now carry their tenant from creation and every alert they raise inherits it, and previously orphaned batch alerts have been reattached to their owning tenant where the owning key still exists, so historical batch alerts appear on the alert surfaces retroactively. Batch alerts follow the same creation rule as screening alerts: created when the item's highest score is \u003e= your alert_threshold. Alert queues can grow for customers who ran batches before this fix; response shapes are unchanged — no version change.","endpoint":"/v1/batch","verification":{"steps":["curl -s -X POST \"$BASE/v1/batch\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"names\":[{\"full_name\":\"Vladimir Putin\"}]}' | jq '.id'","curl -s \"$BASE/v1/batch/status?id=$JOB_ID\" -H \"X-API-Key: $KEY\" | jq '.items[0].alert_id'","curl -s \"$BASE/v1/alerts/$ALERT_ID\" -H \"X-API-Key: $KEY\" | jq '{id: .id, source: .source, status: .status}'","curl -s \"$BASE/v1/alerts?source=batch\" -H \"X-API-Key: $KEY\" | jq '[.data[].id] | index('$ALERT_ID') != null'"],"expect":"the alert_id the batch item reports resolves via GET /v1/alerts/{id} to an alert with source \"batch\", and the same alert is present in the GET /v1/alerts queue — never a 404 on an ID the API itself just returned","audit":"each batch item's immutable screening evidence record is unchanged; the alert it raises is now retrievable and disposable through the standard alert lifecycle with the full case-activity audit trail, closing the gap where batch hits could never be dispositioned","regression":"TestBatchAlertReachesQueue runs the real batch pipeline against a synthetic listed entry and proves the item's alert_id is tenant-scoped, retrievable by ID through the API, and listed in the tenant's queue"}},{"date":"2026-08-29","category":"fixed","summary":"GET /v1/alerts now returns every alert as created: raising your alert_threshold no longer retroactively hides existing lower-score alerts — including open ones mid-investigation — from the list endpoint","details":"The tenant alert_threshold gates alert creation exactly once, at creation time. The list endpoint additionally re-filtered reads by the CURRENT threshold, so tightening it made previously created lower-score alerts — open ones included — vanish from GET /v1/alerts even though the records themselves are immutable and remained retrievable by ID. An examiner asking \"show me every alert you generated\" now gets creation-time reality regardless of later configuration changes. Callers who want a read-time floor pass the existing min_score filter explicitly. Listings can grow for tenants that raised their threshold after alerts existed; response shapes are unchanged — no version change.","endpoint":"/v1/alerts","verification":{"steps":["curl -s \"$BASE/v1/alerts?limit=100\" -H \"X-API-Key: $KEY\" | jq '[.data[].score] | min'","curl -s \"$BASE/v1/account\" -H \"X-API-Key: $KEY\" | jq '.features.alert_threshold'"],"expect":"alerts created before a threshold increase remain in the listing, so the minimum listed score can be below the current alert_threshold; nothing that GET /v1/alerts/{id} can retrieve is absent from the list","audit":"alert records are append-only and were never deleted by threshold changes; this fix makes the primary retrieval surface reflect that stored reality, so list output, per-ID retrieval, and the immutable audit trail all agree","regression":"TestListAlertsSurvivesThresholdRaise creates an alert under a low threshold, raises the threshold above its score, and proves the alert still appears in GET /v1/alerts"}},{"date":"2026-08-29","category":"fixed","summary":"Typed identifier screening: an id type is now matched by case-insensitive prefix against publisher category strings, industry tokens like \"IMO\" are accepted, and a type matching no category returns a structured 400 — a typed request can no longer return a silent zero-match","details":"Screening by identifier with an explicit type previously required the caller's type string to equal the category string the publisher records — but publishers suffix their categories (\"Tax ID No.\", \"National ID No.\") and label vessel IMO numbers \"Vessel Registration Identification\", so a request like ids:[{number:\"8511823\",type:\"IMO\"}] filtered every row and returned a clean zero-match response while the untyped form of the same number matched. Type matching is now case-insensitive prefix against the stored category strings, common industry tokens are translated to the publisher's label (\"IMO\" reaches vessel registration identifiers), and a type that matches no stored category on any screened list is rejected with a structured 400 (code invalid_parameter, param naming the offending field) instead of screening nothing. Batch items with an unmatchable type are marked errored rather than reported as cleanly screened. Typed and untyped forms of the same identifier now return identical matches, and identifier numbers and types are bounded at 200 characters (real identifiers sit well under 100; longer values are rejected with a structured 400). Recall rises for typed requests; 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 '{\"ids\":[{\"number\":\"8511823\",\"type\":\"IMO\"}]}' | jq '{count: .match_count, top: .matches[0].name, score: .matches[0].score}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"ids\":[{\"number\":\"8511823\",\"type\":\"NO-SUCH-CATEGORY\"}]}' | jq '{code: .code, param: .param}'"],"expect":"the typed IMO request returns the same deterministic score-100 vessel match as the untyped form \"IMO 8511823\"; the unmatchable type returns HTTP 400 with code invalid_parameter and param ids[0].type — never a zero-match success","audit":"typed identifier screenings persist to the same immutable audit record as every screening, and rejected types produce no screening record at all — an all-clear on file always reflects identifiers that were actually searched","regression":"TestScreenByIDTypedMatchesUntyped locks typed/untyped equivalence across token, label, and prefix forms on synthetic fixtures; TestScreenTypedIDHTTP locks the structured 400 with the exact offending parameter; TestScreenVesselByIMO keeps the untyped published-form pathway green"}},{"date":"2026-08-29","category":"changed","summary":"AI-generated name variations now cover every OFAC SDN entry — transliterations, romanization-system variants, and cultural equivalents are matched on every screening, refreshed daily as the list changes","details":"Screening has always consulted stored AI-generated name variations as a match pathway alongside primary names and OFAC's own aliases, but production coverage was partial. Every live OFAC SDN entry now carries a generated variation set — common English-press spellings, standard romanization systems (McCune-Reischauer and Revised Romanization for Korean, Wade-Giles and pinyin for Chinese, ALA-LC and press renderings for Arabic and Persian), and cultural equivalents — each stored with a written reason, so a match via this pathway is examiner-explainable, never a black box. A daily job generates variations for newly designated entries as list updates land. Reverse screening (portfolio monitoring) probes the same variation set, so both matching directions benefit. Scores for affected transliterations rise; 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\":\"Osama bin Laden\",\"lists\":[\"ofac\"]}' | jq '{alert: .alert_id, top_sources: [.matches[0].match_sources[] | {source, matched_text}]}'"],"expect":"the common English spelling of a listed name returns a strong match whose match_sources include an ofac_ai_variation pathway with the matched variation text — alongside the primary-name and alias pathways","audit":"every stored variation carries a reason string persisted with it, and each screening's immutable audit record includes the variation pathway and matched text exactly as returned, so an examiner can trace any variation-driven match to its stored justification","regression":"TestScreenFoldTransliteration and the corpus battery lock transliteration recall; the daily variations-backfill job is resumable and skips covered entries, so coverage is monotonic"}},{"date":"2026-08-28","category":"fixed","summary":"Transliteration recall: name variants that differ in their first letter — Kasem/Qasem, Chong/Jong, Osama/Usama — now receive full phonetic credit instead of scoring below their canonical spellings","details":"Classic Soundex encodes every letter of a name into a phonetic class except the first, which it compares literally. That blind spot penalized standard romanizations of the same name that open with a different letter — the Arabic qaf rendered as K or Q (Kasem/Qasem), Korean Ch/J romanization systems (Chong/Jong), and O/U vowel openings (Osama/Usama). Two scoring paths were affected: such variants missed the phonetic corroboration the engine grants equivalent-sounding names, and multi-part names could be mistaken for single-token partial matches and discounted accordingly. Both paths now compare names with the first letter mapped through the same phonetic classes Soundex applies to every other letter, so a screening for a common English rendering of a listed name scores in line with the canonical spelling. Scores for affected variants rise; 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\":\"Kasem Solaimani\"}' | jq '{status: .status, top: .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\":\"Qasem Soleimani\"}' | jq '{status: .status, top: .matches[0].score, alert: .alert_id}'"],"expect":"the transliterated and canonical spellings of the same listed name both return a strong match against the same designation and both raise an alert — the first-letter variant no longer scores materially below the canonical form","audit":"every screening's persisted record carries the scores as returned at decision time, so pre-fix and post-fix decisions each reflect the scoring in force when they were made and remain reproducible via point-in-time replay","regression":"TestScreenFoldTransliteration proves a first-letter variant of a synthetic listed name clears the alert threshold without tripping the partial-name discount while a genuine surname-only query still takes it; TestSoundexFoldMatch and TestApplyNameTokenSignal lock the phonetic equivalence table both ways"}},{"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."}}
