{"entries":[{"date":"2026-09-16","category":"fixed","summary":"A listed name that contains a parenthetical — VEST SPECTRUM (S) PTE. LTD., NOOR ENERGY (MALAYSIA) LTD. — now scores 100 and raises an alert when screened exactly as the list publishes it","details":"Some sanctioned entities are published with a parenthetical in the name itself: a place of incorporation, an abbreviation, a legal form. Screening one of those names exactly as OFAC publishes it did not score 100 against its own record. The engine dropped the parenthetical from the name you submitted — and, by a defect, everything after it too — before comparing, while the listed name was compared in full, so the two could never agree. Forty-two SDN primary names and about sixty aliases were affected; on a sample of fourteen, six exact matches of a listed name raised no alert at all, and summary.top_match named an unrelated record. That is a false negative on the simplest possible input, and it is closed. The name you submit is now screened exactly as written. A submitted name that carries a parenthetical is additionally screened with the parenthetical removed, and each record keeps whichever comparison scored higher — so an annotation of your own in parentheses still reaches the record it reached before. WHAT YOU WILL SEE: an exact match of a published name that contains a parenthetical now returns the publishing pathway at 100, alerts, and names itself as summary.top_match. Names without a parenthetical are unaffected. Additive — no field changed meaning and no version is minted.","endpoint":"/v1/screen","verification":{"steps":["curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"VEST SPECTRUM (S) PTE. LTD.\",\"limit\":100}' | jq -c '{alert:(.alert_id!=null), top:.summary.top_match.uid, sdn:[.matches[]|select(.uid==\"11309\")|.match_sources[]|select(.source==\"ofac_sdn\")|.trigram_score]}'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"WEE TIONG (S) PTE LTD\",\"limit\":100}' | jq -c '{alert:(.alert_id!=null), top:.summary.top_match.uid, sdn:[.matches[]|select(.uid==\"25687\")|.match_sources[]|select(.source==\"ofac_sdn\")|.trigram_score]}'"],"expect":"step 1 prints {\"alert\":true,\"top\":\"11309\",\"sdn\":[100]} and step 2 prints {\"alert\":true,\"top\":\"25687\",\"sdn\":[100]}: each exact match of a published name alerts, names its own record as the top match, and reports the ofac_sdn pathway at full similarity","audit":"the stored screening carries the name exactly as you submitted it, so an examiner replaying the record sees the same input and the same 100","regression":"TestExactNameParentheticalScores100, TestExactNameParentheticalTopMatchIsItself, TestExactNameParentheticalHTTP, TestParentheticalAnnotationStillReachesTheRecord, TestStripParentheticals, TestQueryForms, TestMergeFormsTakesBestPerRecordAndPerSource"}},{"date":"2026-09-15","category":"added","summary":"Every API response now carries a Server-Timing header reporting how long the server itself spent on the request, so you can measure our latency from your own client instead of taking our word for it","details":"Until now the only way to know how long a screening took was to time it yourself from the outside, which measures your network as much as our work. Every response under /v1/ now carries the standard Server-Timing header — total;dur=\u003cmilliseconds\u003e — holding the server's own measurement of that request. It is the figure our own monitoring records, not a separate estimate, and it is present on error responses too, which are the ones most worth timing when something is wrong. WHAT YOU WILL SEE: one additional response header, readable with curl -D- or from any HTTP client's header map, on every /v1/ endpoint including failures. Nothing in any response body changed, no field changed meaning, and no version is minted. A retried request served from an idempotency cache reports the time that retry actually took rather than replaying the original measurement. SECOND, an unexpected server fault now returns a structured error response carrying a request_id, in the same shape as every other error, rather than closing the connection.","endpoint":"/v1/screen","verification":{"steps":["curl -sD - -o /dev/null -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Ali Hassan\"}' | grep -i '^server-timing'","curl -sD - -o /dev/null \"$BASE/v1/lists\" | grep -i '^server-timing'"],"expect":"both steps print a Server-Timing header of the exact form total;dur=\u003cnumber\u003e, where the number is the milliseconds the server spent on that request and is greater than zero; the header carries the single total metric and nothing else","audit":"the figure is the server's own measurement of the same request the X-Request-Id header identifies, so a slow call can be quoted back to us by request_id with a latency we both agree on","regression":"TestServerTimingScopedToAPIPaths, TestServerTimingCarriesOnlyTotal, TestServerTimingPresentOnErrorResponses, TestRequestLogPanicBeforeWriteBecomesStructured500"}},{"date":"2026-09-15","category":"fixed","summary":"Portfolio monitoring now reaches the same alerting decision a live screening reaches: a monitored name one or two characters from a newly designated party raises an alert, and monitoring alerts carry the same explanation a screening carries","details":"Screening a name yourself and having us watch a name for you were not the same control, and the difference was in the direction that matters. A name you screen through the API that sits one or two characters from a published sanctions name raises an alert despite its score, because that is the shape a deliberate evasion takes. A name sitting in your monitored portfolio did not: when a list update designated a party it was that close to, the comparison happened, the score came out just under your threshold, and nothing was raised. That is the case portfolio monitoring exists for — someone screened clean when you onboarded them and designated later — so it was the wrong place to have the gap. It is closed: the delta path now runs the same scoring pipeline the live and batch paths run, and the alert carries alert_reason naming the rule, exactly as a live screening does. SECOND, monitoring alerts can now explain themselves. The stored evidence records how much of the score came from phonetic name agreement, and that figure was previously written as zero on every monitoring alert — including, by construction, the alerts where phonetic agreement was the thing that carried the match over the line. An analyst reading one of those was told the phonetic contribution was nothing when it was most of the score. WHAT YOU WILL SEE: a small number of additional monitoring alerts, each below your threshold and each carrying the signal naming the two tokens and the characters between them, and a real figure where there was a zero. Scores are unchanged, so any threshold you have tuned behaves as before. Additive — no existing field changed meaning and no version is minted.","endpoint":"/v1/alerts","verification":{"steps":["curl -s \"$BASE/v1/alerts?source=monitoring\u0026limit=20\" -H \"X-API-Key: $KEY\" | jq -c '[.data[] | {id, score, alert_reason, boost: .match_details.soundex_boost}]'","curl -s -X POST \"$BASE/v1/screen\" -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladmir Pootin\"}' | jq -c '{alert_reason, score: ([.matches[].score] | max)}'"],"expect":"step 2 shows the live path raising an alert below the threshold with alert_reason \"name_near_miss\"; step 1 shows monitoring alerts reaching the same decision — any raised on proximity carry the same alert_reason, and no alert reports a soundex_boost of zero, since that field is written only when phonetic agreement contributed","audit":"alert_reason and the phonetic contribution are stored with the alert, not only returned, so a reviewer asking why a below-threshold monitoring alert exists reads the answer off the record","regression":"TestReverseScreenNearMissAlerts, TestReverseScreenAlertCarriesBoost, TestMonitoringPipelineMatchesForwardOnSharedSteps, TestMonitoringPipelineUsesTheRequestName, TestReverseScreenScale"}},{"date":"2026-09-15","category":"added","summary":"The changelog can now be watched: it carries an ETag, an entry count and a content fingerprint, so a conditional request tells you whether anything changed without downloading and comparing every entry","details":"If you run a control that watches this vendor for behaviour changes, it previously had nothing stable to watch. Entries carry no identifier, several can share a date, and the version header deliberately does not move for additive or corrective changes — so the only correct way to detect a change was to store every entry and compare them on each poll. A customer operating exactly that control missed four behaviour changes we shipped in one day, and concluded the fault was theirs. It was ours. The response now carries entry_count and fingerprint, and the same fingerprint is served as an ETag: send it back as If-None-Match and an unchanged changelog answers 304 with no body. The fingerprint covers the entire content of every entry, so nothing can change without it changing. WHAT YOU WILL SEE: two new fields and a validator header. Polling with If-None-Match costs one request and tells you plainly whether to look. Additive — no existing field changed meaning and no version is minted.","endpoint":"/v1/changelog","verification":{"steps":["curl -s \"$BASE/v1/changelog\" | jq -c '{entry_count, fingerprint, actual: (.entries | length)}'","ETAG=$(curl -sI \"$BASE/v1/changelog\" | awk 'tolower($1)==\"etag:\"{print $2}' | tr -d '\\r'); curl -s -o /dev/null -w '%{http_code}\\n' -H \"If-None-Match: $ETAG\" \"$BASE/v1/changelog\""],"expect":"step 1 returns an entry_count equal to the number of entries and a non-empty fingerprint that is identical across repeated requests; step 2 returns 304, and a request with no If-None-Match still returns the full list with 200","audit":"the fingerprint in force on a given date can be recorded alongside your own change-control evidence, so what this vendor was publishing at that moment is provable afterwards rather than asserted","regression":"TestChangelogIsDetectable, TestChangelogFingerprintCoversEveryField"}},{"date":"2026-09-14","category":"fixed","summary":"Date of birth is compared on the Australian list, where it was silently ignored; a record that lists several possible birth dates is now compared against all of them; an attribute that could not be evaluated always says so; every alert names the rule that raised it; and a match one character from part of a longer name no longer alerts when the rest of that name is absent","details":"Five changes, all of them about the same thing: a check that cannot run must say so rather than look like a check that passed. FIRST, dates of birth on the Australian list were being ignored. That publisher ships its list as a spreadsheet, and a date in a spreadsheet is a number; the value was stored in that raw form, nothing downstream could read it as a date, and the comparison quietly did not happen. A correct date of birth, a date of birth decades wrong, and no date of birth at all produced identical results on that list while every other list discriminated normally. Dates are now decoded when the list is loaded, and existing records were repaired. SECOND, publishers often record several possible birth dates for one person, sometimes with a note that they are approximate. Those values could not be read either. A record that lists several dates is now compared against all of them, and only contradicts you if your date matches none — matching any one of the dates the publisher itself offers is agreement, not a conflict. THIRD, when an attribute cannot be evaluated at all, the response now says so instead of staying silent. Before, an unreadable value was indistinguishable from a value that agreed. FOURTH, a match that nothing else could explain now carries a signal saying no attributes were supplied to discriminate with, so a full-confidence alert no longer arrives with an empty explanation and send us a date of birth or a country is stated rather than implied. FIFTH, the alert raised for a name a character or two from a listed name now requires that the part of the listed name identifying the person be present in your request at all. It was enough for any single part of a longer record to be close, so a given name plus a patronymic could alert against someone whose surname appeared nowhere in the request. AND: every alert now carries alert_reason naming which rule raised it, on the screening response and on the stored alert. WHAT YOU WILL SEE: on the Australian list, supplying a date of birth now changes the outcome where it previously did not, and a wrong one lowers the score as it does elsewhere. Some responses carry additional zero-impact signals reporting checks that could not run — these never move a score. Slightly fewer alerts of the near-miss kind. Read alert_reason rather than inferring the rule from the score; it is absent on alerts created before today, which were raised before the reason was recorded. Additive — no existing field changed meaning and 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\":[\"australia\"]}' | jq -r '.matches | max_by(.score) | .date_of_birth'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladimir Putin\",\"date_of_birth\":\"1990-01-01\",\"lists\":[\"australia\"]}' | jq -c '{score: (.matches|max_by(.score)|.score), signals: [(.matches|max_by(.score)).dismissal_signals[] | {reason_code, impact_on_score}]}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Vladmir Pootin\"}' | jq -c '{alert: (.alert_id != null), alert_reason}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Sergei Ivanov\"}' | jq -c '.matches | max_by(.score) | {score, signals: [.dismissal_signals[].reason_code]}'"],"expect":"step 1 returns a real date, never a bare integer; step 2 returns a score well below the one the same request gets without a date of birth, carrying a signal naming the date-of-birth conflict with a negative impact — and supplying no date of birth at all still matches and still alerts, because this applies the attribute when given rather than requiring it; step 3 returns an alert with alert_reason \"name_near_miss\", a rule that raises an alert below your threshold and now says so by name; step 4 returns a top match that carries at least one signal — the alert is correct and is not suppressed, but it no longer arrives with nothing attached","audit":"alert_reason is stored with the alert, not only returned, so a reviewer asking why a below-threshold match raised an alert reads the answer off the record rather than re-deriving it; alerts created before this change report it as absent rather than inferring one","regression":"TestNormalizeDOBDecodesExcelSerials, TestNormalizeDOBLeavesEverythingElseAlone, TestImportFixtureDecodesDOBAndIsStable, TestCompareDOBValueMultiValue, TestCompareDOBValueKeepsSingleValueListsUnchanged, TestNearMissRequiresIdentifyingComponent, TestApplyInsufficientDataSignal, TestAlertReasonNamesTheRule, TestSeededMissBenchmark, TestFalsePositiveBenchmark"}},{"date":"2026-09-14","category":"fixed","summary":"A name where one part is truncated or a character off now reaches the designation it belongs to, instead of being found and then set aside; matches are ordered highest score first; and a near miss raises an alert on batch screening as it already did on a single screen","details":"Three changes that move in the same direction: a match that should reach a reviewer now does. FIRST, the control that sets aside a hit when only one part of a longer name matched — OFAC's own guidance that a surname alone is not a valid match — was applying when you had in fact supplied every part of the name and one of them was damaged. Screening a person whose first name was cut short, or carried a single letter of transliteration difference, found the listed party and then set the result aside as though you had supplied nothing for that name. It no longer does: a recognisably degraded form of a name counts as having been supplied. A hit corroborated by only one part of a name is still set aside, exactly as before. SECOND, matches[] is now ordered by the score the response reports, highest first, so the first element is the top match. It was ordered before the response's own adjustments were applied and never reordered, so the first element could be a low-scoring match while a higher one sat further down; the summary and the alert decision always used the highest, so the response disagreed with itself rather than being wrong. Equal scores keep a stable order across identical requests. THIRD, a match sitting one or two characters from a listed name raises an alert on batch screening, which it already did on a single screen. WHAT YOU WILL SEE: some names that previously returned a low score now return a high one and may raise an alert — these are matches the engine had already found. On a 3,375-name benchmark of ordinary customer names the change added two alerts. If you read matches[0], you now get the top match; if you scan for the maximum, nothing changes. Batch results may carry alerts they did not before, each explaining itself through the same signal the single-screen path uses. No field changed meaning and 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\":\"Hass Aweys\",\"lists\":[\"eu\"]}' | jq -c '{alert: (.alert_id != null), top: ([.matches[]] | max_by(.score) | {uid, score, signals: [.dismissal_signals[].reason_code]})}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Wei Chen\",\"country\":\"CN\"}' | jq -c '[.matches[]] | max_by(.score) | {score, signals: [.dismissal_signals[].reason_code]}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Fatima Al-Sayed\"}' | jq -e '[.matches[].score] as $s | ($s == ($s | sort | reverse)) and (.matches[0].score == ($s | max))'"],"expect":"step 1 returns the listed designation at a score above 90 with an alert, and its signals do NOT include partial_name_match_single_token — the truncated first name no longer sets the match aside; step 2 still carries partial_name_match_single_token, so a genuinely one-part match is still discounted; step 3 exits 0, meaning matches[] is ordered descending and matches[0] is the highest-scoring match","audit":"the ordering applies to the matches persisted with the screening as well as the ones returned, so an exported decision and the response a reviewer saw list them identically","regression":"TestApplyNameTokenSignalSparesDegradedTokens, TestDegradedFormRejectsDifferentNames, TestApplyNameTokenSignalFiresOnFoldCollision, TestFinalizeScoresOrdersByFinalScore, TestBatchNearMissAlerts, TestSeededMissBenchmark, TestFalsePositiveBenchmark"}},{"date":"2026-09-14","category":"added","summary":"A match one or two characters from a listed name now raises an alert and says why, where it previously returned a clean-looking weak result with nothing attached","details":"A one-character difference from a published sanctions name is a fundamentally different object from an unrelated name that happens to score similarly, and until now the response could not tell them apart. Screening a listed name with a single letter changed could return a weak-match score, raise no alert, and carry no explanation at all — the score was plain literal similarity and the threshold fell in the gap. Responses now carry a name_near_miss entry in dismissal_signals when a match sits within a very small edit distance of the listed name AND is already close to the alert threshold. It reports the two tokens that nearly matched and the number of characters between them, so a reviewer or an agent can act on the proximity rather than inferring it from a score. Both conditions are required, and the second is not decoration: character proximity alone does not distinguish a deliberate near-miss from an ordinary coincidence, because an unrelated name can sit exactly as close lexically as a real evasion does — we tested that directly and the two are inseparable on distance alone. The signal never changes a score or a verdict. It does, however, raise an alert: a match flagged this way alerts even though its score sits below your threshold, because a one-character difference from a published sanctions name is the shape a deliberate evasion takes, and under strict liability the expensive direction is the miss. WHAT YOU WILL SEE: a small number of additional alerts, each carrying the signal that explains why it was raised despite the score, and each identifying the two tokens and the characters between them so it can be dispositioned quickly. Across a 3,375-name benchmark of ordinary customer names the signal appears on eighteen — well under one percent — and those are the only additional alerts it produces. Scores themselves are untouched, so any threshold you have tuned against Noble scores continues to behave as before. Additive — no existing field changed meaning and 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\":\"Rafael Caro Quintebo\"}' | jq -c '{alert: (.alert_id != null), top: ([.matches[]] | max_by(.score) | {score, near_miss: [.dismissal_signals[] | select(.reason_code==\"name_near_miss\") | {request_value, target_value, edit_distance, impact_on_score}]})}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Rafael Caro Quintero\"}' | jq -c '{score: ([.matches[].score] | max), alert: (.alert_id != null)}'"],"expect":"step 1 returns a match scoring under the alert threshold that nonetheless carries a non-null alert_id, plus a name_near_miss signal whose request_value and target_value differ by one character, with edit_distance 1 and impact_on_score 0 — the score is untouched, the proximity is stated, and the alert is raised on the proximity rather than the score; step 2, the name spelled correctly, returns 100 with an alert as it always has","audit":"the proximity is recorded on the persisted screening, so a reviewer of a below-threshold result can see it was a character from a listed name rather than having to re-derive that from the score","regression":"TestApplyNameNearMissSignal, TestLevenshtein, TestNearMissAlertsBelowThreshold, TestFalsePositiveBenchmark"}},{"date":"2026-09-14","category":"fixed","summary":"Screening an alias by its complete published name now matches it in full — previously only part of the name was searchable, so an exact alias could return a weak score and no alert","details":"OFAC publishes alternate names for designated parties, and stores many of them as separate given and family name fields. Noble searches those aliases three ways: the given name, the family name, and the two joined together. The joined form was not being maintained — it was filled in once when the column was introduced and never updated afterwards, so for aliases loaded since then it was empty and the search index built on it held nothing. The practical effect was that supplying an alias exactly as OFAC publishes it could only ever be compared against one half of it. One published alias of a designated party returned a score of 77.8 and no alert, where matching the whole string returns 100 and alerts. Across the OFAC alias set, 8,622 aliases that carry both a given and a family name could not reach the alert threshold on an exact match of their own published name; every one of them now does. The joined form is now derived by the database itself rather than maintained by the import, so it cannot fall out of step with the data again, and a test now fails the build if any search column in any sanctions list is left to be maintained by hand. WHAT YOU WILL SEE: more matches and some higher scores where an alias is involved, and alerts on exact alias matches that previously returned a weak result. This restores matching that was always intended; it is not a loosening of the threshold. Measured against the 3,375-name benchmark corpus the change adds eight alerts in total, so the recall is recovered at very little cost in noise. Request and response shapes are unchanged.","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\":\"MAKLED GARCIA, Valed\"}' | jq -c '{score: ([.matches[].score] | max), alert: (.alert_id != null), uid: ([.matches[]] | max_by(.score) | .uid)}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"MAKLED GARCIA, Walid\"}' | jq -c '{score: ([.matches[].score] | max), alert: (.alert_id != null)}'"],"expect":"step 1 returns score 100 with an alert against uid 11500 — \"MAKLED GARCIA, Valed\" is an alias OFAC publishes for that designation, and it previously returned 77.8 with no alert; step 2, the primary name, returns 100 with an alert as it always has","audit":"an exact match on a published alias is now recorded at full strength with the alert it warrants, so a reviewer of the screening record sees the designation was hit rather than a weak score with no alert attached","regression":"TestSearchColumnsAreGenerated, TestOFACAliasSearchColumnPopulated, TestFalsePositiveBenchmark, TestScreenAliasQuality"}},{"date":"2026-09-13","category":"changed","summary":"Phonetic name agreement now scales with how much of the name literally matched, so a common-surname collision no longer reaches the alert threshold on phonetics alone","details":"A match score is literal name similarity plus a phonetic agreement amount, and that amount was flat: a surname agreeing phonetically contributed the same fixed number of points whether the literal similarity was high or very low. On a common surname the phonetic component was therefore most of the score while the literal match contributed almost nothing. Measured against production, five of six alerts raised by a twenty-name battery of ordinary customer names were carried over the threshold this way — one reached a score of 86.3 against a listed party sharing only a surname, with an entirely different given name. The phonetic amount is now weighted by the literal similarity it corroborates, so the same agreement contributes more to a strong name match than to a weak one. Phonetic agreement is corroborating evidence, and it should amplify a real match rather than manufacture one. WHAT YOU WILL SEE: fewer alerts, and lower scores on matches that were resting on phonetic agreement rather than on the name itself. Recall was not traded for it — on the benchmark that measures MISSED listed parties, five of seven sanctions lists are unchanged and two move by a single variant each. A more aggressive weighting was measured and deliberately rejected because it DID trade recall: a transliteration variant has low literal similarity by construction and is exactly the case phonetic matching exists to catch, so weighting too hard on literal similarity penalises it. Scores are directly comparable before and after only for matches with no phonetic component; if you have tuned thresholds of your own against Noble scores, re-check them. Request and response shapes are unchanged.","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\":\"Fatima Al-Sayed\"}' | jq -c '[.matches[]] | max_by(.score) | {score, target: (.first_name + \" \" + .last_name), boost: [.dismissal_signals[] | select(.reason_code==\"phonetic_name_boost\") | .impact_on_score]}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Ahmed Hassan\"}' | jq -c '[.matches[]] | max_by(.score) | {score, target: (.first_name + \" \" + .last_name)}'"],"expect":"in step 1 the phonetic contribution reported in impact_on_score is strictly less than it would be for a fixed amount, and strictly less than the same agreement contributes to step 2's stronger literal match — the contribution scales with literal similarity, which is the guarantee this change makes. Step 2 still returns 100 against the listed \"Ahmad Hassan\", the transliteration case phonetic matching exists for, showing recall intact. (Step 1 also falls below the alert threshold today, but read the scaling relation rather than that margin: it is narrow, and an ordinary list update can move it either way without anything changing here.)","audit":"every score that includes a phonetic component reports that component's actual contribution in dismissal_signals, so an analyst reviewing an alert can see how much of the score came from literal similarity and how much from phonetic agreement","regression":"TestApplyPhoneticNameBoostSignal, TestFalsePositiveBenchmark, TestSeededMissBenchmark, TestReverseScreenAIVariationParity, TestScreenBatchVariantParity"}},{"date":"2026-09-13","category":"fixed","summary":"An exact match on an alias OFAC publishes is no longer halved as a partial-name match — it scores in full and raises an alert","details":"OFAC FAQ 5 Step 3 asks whether just one of two or more names matches, i.e. just the last name, and directs that such a hit is not a valid match. Noble applies that as a scoring discount. The discount was being measured against the designation's PRIMARY name even when the match came from an alias — and an alias, by construction, shares few or no tokens with the primary name. So screening the exact alias string OFAC publishes for a designated party matched at full similarity and was then halved for overlapping the primary name in only one token, landing under the alert threshold. An exact hit on a published designation raised nothing. The discount is now skipped when any pathway matched a multi-token name the publisher lists — a primary name, an alias, or a stored variation — in its entirety, because in that case all of the name matched and the rule's premise does not hold. The control itself is unchanged in every other case: a bare surname against a multi-token designation still takes the discount, including when that surname is itself a published alias, and an approximate alias hit still takes it. Alias strength is unaffected — a weak alias is still reported as weak, it is simply no longer discounted twice. Measured against the 3,375-name benchmark corpus the change moves the alert rate not at all (1094/3375 before and after); it is narrow by construction and affects only exact matches on multi-token published names.","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\":\"EL TURCO WALID\"}' | jq -c '{score: .matches[0].score, uid: .matches[0].uid, alert: .alert_id, signals: [.matches[0].dismissal_signals[].reason_code]}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"WALID\"}' | jq -c '{score: .matches[0].score, signals: [.matches[0].dismissal_signals[].reason_code]}'"],"expect":"step 1 returns the uid-11500 match at score 100 with an alert_id and no partial_name_match_single_token signal — \"EL TURCO WALID\" is an alias OFAC publishes for that designation; step 2, a bare surname, still carries partial_name_match_single_token and scores 50, showing the FAQ 5 Step 3 control intact","audit":"a screening whose input reproduces a published alias in full now records a full-strength match and the alert it warrants, so the audit trail shows the designation was hit rather than showing a below-threshold score with no alert","regression":"TestApplyNameTokenSignalSparesFullPublishedAlias, TestApplyNameTokenSignal, TestApplyNameTokenSignalSpares150Variants, TestFalsePositiveBenchmark, TestSeededMissBenchmark"}},{"date":"2026-09-13","category":"changed","summary":"The CSV export gains a jurisdiction_nexus_status column, so a flat export can tell a geography we hold no determination about from one we evaluated and cleared","details":"The CSV export carried the declared region and the embargo advisory, which meant a screening whose declared region we hold no determination about and one whose region we evaluated and excluded rendered identically: a region beside a blank advisory. The structured responses distinguish these, and CSV is the format pulled for a regulatory review precisely because it is flat and readable, so it was the wrong surface to leave ambiguous. A jurisdiction_nexus_status column is added carrying embargoed, evaluated_not_embargoed, or no_determination, and left blank when the screening declared no geography at all — blank means nothing was declared, not nothing was found. The column is inserted after jurisdiction_authority and before deep_screen. A consumer reading columns by NAME is unaffected. A consumer reading by fixed position must re-read the header row: this is the second such change in ten days, after the three columns added on 2026-09-03, and both were made before the export had positional consumers we know of. The JSON export is unchanged and remains the recommended integration.","endpoint":"/v1/export","verification":{"steps":["T1=$(curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\",\"location\":{\"country\":\"UA\",\"region\":\"Crimea Republic\"}}' | jq -r .trace_id)","T2=$(curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\",\"location\":{\"country\":\"UA\",\"region\":\"Kherson\"}}' | jq -r .trace_id)","curl -s \"https://api.noblesight.io/v1/export?format=csv\u0026trace_id=$T1\" -H \"X-API-Key: $KEY\" | head -1 | tr ',' '\\n' | grep -n jurisdiction_nexus_status","for T in $T1 $T2; do curl -s \"https://api.noblesight.io/v1/export?format=csv\u0026trace_id=$T\" -H \"X-API-Key: $KEY\" | tail -1 | awk -F, '{print $14}'; done"],"expect":"step 3 reports jurisdiction_nexus_status at column 14; step 4 prints \"no_determination\" then \"evaluated_not_embargoed\" — two rows that were previously identical","audit":"a CSV pull is now sufficient to establish whether a declared geography was one Noble holds a determination about, so a reviewer can tell a disclosure that did not land from one that was evaluated and cleared without returning to the API","regression":"TestExportCSVRowNexusStatus, TestExportCSVRowJurisdiction, TestExportCSVRow"}},{"date":"2026-09-13","category":"fixed","summary":"Replaying a point-in-time screening no longer reports a false mismatch on its jurisdiction advisory","details":"A screening submitted with as_of asks what was legally true on a past date, and is issued the advisory that applied then. Replay was re-deriving that advisory from the record of what our embargo table has carried over time, resolved at the same past date — and that record begins when the advisory feature shipped. A screening as of any date before then therefore replayed with no advisory at all, contradicted the record it was verifying, and returned a verdict of mismatch for a screening that had in fact reproduced exactly. On the endpoint whose purpose is demonstrating that stored decisions reproduce, a false alarm is the most costly kind of wrong. Replay now keeps the two questions apart: the time the screening ran determines which version of the embargo table applied, and the as-of instant determines what was legally in force. Both were previously collapsed onto one. Ordinary screenings without as_of are unaffected, and the behaviour that motivated the original design is preserved — a screening issued under a program later revoked still replays carrying the advisory it was given, and a screening from before the advisory existed still replays with none.","endpoint":"/v1/export/replay","verification":{"steps":["curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\",\"country\":\"IR\",\"as_of\":\"2024-01-01\"}' | jq -c '{trace: .trace_id, j: .jurisdiction_risk.jurisdiction}'","curl -s -X POST https://api.noblesight.io/v1/export/replay -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"trace_id\":\"\u003ctrace_id from step 1\u003e\"}' | jq -c '{verdict, j: .replayed.jurisdiction_risk.jurisdiction, diffs: [.differences[].field]}'"],"expect":"step 1 returns jurisdiction \"IR\"; step 2 returns verdict \"match\" with the same jurisdiction \"IR\" and a differences array containing no jurisdiction field. Before this fix step 2 returned verdict \"mismatch\" with jurisdiction_risk.jurisdiction listed as stored \"IR\", replayed \"absent\".","audit":"a point-in-time screening and its replay now agree on the embargo that applied, so the reproduction evidence an examiner is shown does not contradict the record it verifies","regression":"TestReplayOfAsOfScreeningMatches, TestReplayOfAsOfScreeningReproducesItsAdvisory, TestReplayStillHonoursTheKnowledgeTimeline"}},{"date":"2026-09-13","category":"added","summary":"Screening responses now report which geographic declaration was evaluated for an embargo nexus, so a region we have no determination about is no longer indistinguishable from one we evaluated and cleared","details":"The embargo advisory answers what was found. Nothing answered what was looked at, and on a signal that only fires for geography you declare, that gap mattered: a request declaring the region \"Kherson\" — evaluated, with a cited determination that no covered-region designation exists — and a request declaring a value we hold no determination about both returned the same empty answer. A caller who mistyped a region believed they had disclosed a nexus and had not, and nothing in the response said otherwise. A new jurisdiction_nexus object reports the declaration that was evaluated, the request field it came from, and one of three statuses: embargoed (always accompanied by the advisory), evaluated_not_embargoed (we hold a determination and it is not an embargo), or no_determination (we hold none for what you sent). It is present whenever a request declares any geography and absent when none is declared. Because precedence is first-resolving, the echoed value is not always the most specific field supplied: declaring country IR with region IR-07 reports IR, since we hold no determination about that province and the country embargo therefore stands — the fall-through is now visible rather than inferred. The object is persisted with the screening, returned per item on batch, and reproduced on replay. Additive: no existing field changed meaning and 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\":\"Zzyxx Cleanname\",\"location\":{\"country\":\"UA\",\"region\":\"Crimea Republic\"}}' | jq -c '{risk: .jurisdiction_risk, nexus: .jurisdiction_nexus}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\",\"location\":{\"country\":\"UA\",\"region\":\"Kherson\"}}' | jq -c '{risk: .jurisdiction_risk, nexus: .jurisdiction_nexus}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\",\"location\":{\"country\":\"IR\",\"region\":\"IR-07\"}}' | jq -c '{risk: .jurisdiction_risk.jurisdiction, nexus: .jurisdiction_nexus}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\"}' | jq -c '.jurisdiction_nexus'"],"expect":"step 1 returns a null risk with nexus status \"no_determination\", evaluated \"Crimea Republic\", source \"location.region\"; step 2 returns a null risk with status \"evaluated_not_embargoed\" — the two are no longer the same answer; step 3 returns jurisdiction \"IR\" with nexus evaluated \"IR\" and source \"location.country\", showing the province deferred to its country; step 4 returns null, because no geography was declared","audit":"the persisted screening now records the interpretation alongside the declaration, so a reviewer can establish which geographic input the engine acted on rather than inferring it from the precedence rules","regression":"TestScreenJurisdictionNexusDisclosure, TestScreenNexusPersisted, TestJurisdictionNexusStatuses, TestJurisdictionNexusAgreesWithAdvisory, TestNexusEmbargoBeatsExclusion, TestNexusOnRecordFollowsTheTableNotToday, TestScreenDecodesJurisdictionNexus, TestBatchDecodesJurisdictionNexus, TestCompareReplayNexusAsymmetric, TestSummarizeScreenNarratesUnrecognizedNexus"}},{"date":"2026-09-13","category":"added","summary":"A match that nothing reduced now reports what raised its score, so an alert is no longer the one result in a screening with no explanation attached","details":"Each match carries structured signals explaining its score, but until now those recorded only reductions. A reduction large enough to record usually drops a match below the alert threshold, so the matches that survive to raise an alert were the ones nothing had reduced — and they arrived with an empty explanation while lower-scoring matches in the same response were fully explained. An analyst opened the alert, saw a high score with nothing attached, and had to investigate from scratch. A match score is its literal name similarity plus a phonetic name agreement, and that phonetic amount is flat: it does not scale with how much of the name matched, so on a common surname it can be most of the score while literal similarity stays low. Where nothing reduced a match, the response now carries a phonetic_name_boost entry naming how many points came from phonetic agreement rather than literal similarity, and the list text that produced it. It is the one entry whose score impact is positive, and it never changes a score — it accounts for one. Matches that were already reduced are unchanged, so the reason an analyst needs is not buried under a second entry. Each match pathway additionally reports soundex_boost alongside its existing trigram_score, so the two can be read as the sum they are. Additive: fields were added and none changed meaning, 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\":\"Maria Garcia\",\"country\":\"MX\"}' | jq -c '.matches | max_by(.score) | {score, signals: [.dismissal_signals[] | {reason_code, impact_on_score}], sources: [.match_sources[] | {trigram_score, soundex_boost}]}'","Assert the invariant on any screening that raised an alert: the top-scoring match carries at least one signal."],"expect":"the top match carries at least one dismissal_signals entry; where nothing reduced it that entry is reason_code \"phonetic_name_boost\" with a positive impact_on_score, and its match_sources show a trigram_score and a soundex_boost that sum to the pathway's contribution","audit":"the signal is recorded on the persisted screening response, so GET /v1/export?trace_id= shows for any screening in the retention window how much of a match's score came from phonetic agreement rather than literal similarity","regression":"TestApplyPhoneticNameBoostSignal covers the explained, already-penalized, and no-boost cases and asserts the score is never changed"}},{"date":"2026-09-12","category":"fixed","summary":"A name matching a listed person on only one short token is now discounted and says so — previously some such matches scored as though the whole name agreed, and reached the alert threshold with no explanation attached","details":"Matching a person on just one token of a multi-token name is not a valid match on its own, so those matches are discounted and carry a partial_name_match_single_token reason code. Deciding which tokens agree uses phonetic equivalence, so the discount does not misfire on transliteration and spelling variants the engine correctly treats as the same name. That equivalence was too permissive in one respect: it generalized a name's first letter to a class of eight letters, which made some unrelated short surnames compare as equal — so a query agreeing on a first name only could read as agreeing on the whole name, take no discount, and arrive as an alert with an empty explanation. Token agreement now additionally requires the phonetic code to carry enough detail to be meaningful once the first letter has been generalized, which it does for genuine variants and does not for unrelated short names. Affected matches score lower and now carry the reason code, so some that previously raised alerts no longer will. Transliteration variants are unchanged and still take no discount — that direction is guarded by tests in both directions, because discounting a real variant would push a confirmed match below the alert threshold. 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\":\"Wei Chen\",\"country\":\"CN\"}' | jq -c '.matches | max_by(.score) | {score, matched: [.match_sources[].matched_text], signals: [.dismissal_signals[].reason_code]}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Kim Chong Un\"}' | jq -c '.matches | max_by(.score) | {score, signals: [.dismissal_signals[].reason_code]}'"],"expect":"the first call's top match either no longer agrees on a single short token or carries reason_code \"partial_name_match_single_token\" with a negative impact_on_score; the second call still returns a top score of 100 with no partial_name_match_single_token entry, because a first-letter transliteration variant agrees on the whole name","audit":"the discount and its reason code are recorded on the persisted screening response, so GET /v1/export?trace_id= shows which tokens were held to agree and what the discount cost, for any screening in the retention window","regression":"TestSoundexNameTokenMatch and TestSoundexNameTokenMatchStricterThanFold lock the equivalence in both directions; TestApplyNameTokenSignalFiresOnFoldCollision covers the reported case; TestApplyNameTokenSignalSpares150Variants proves transliteration variants still take no discount"}},{"date":"2026-09-12","category":"fixed","summary":"Match scores and score impacts are reported to one decimal place instead of carrying meaningless trailing precision — a score that read 91.15384638309479 now reads 91.2","details":"Name similarity is computed in the database at single precision and widened for scoring, so every score carried a fraction of representation residue, and multiplying by attribute penalties compounded it. The result reached the API raw: scores rendered as 91.15384638309479 and 24.500000208616257, an average_score of 52.36538474261761, and score impacts of -21.000000178813934. Those numbers are read by compliance analysts and quoted in examination evidence, where fifteen digits imply a confidence the matching engine does not have and invite a reviewer to reconcile figures that were never meant to be exact. Scores, the mean score, and each dismissal signal's score impact are now rounded to one decimal place, which is the engine's real resolution. Rounding is applied where scores are calculated rather than when they are serialized, so the score returned, the score stored for the audit record, and the score compared against the alert threshold are the same number — a response that disagreed with its own audit record would be the more serious defect. One consequence worth stating: at an alert threshold this can only round a score up into the range that raises an alert, never out of it, so the change can add an alert for a score within a tenth of the threshold and can never suppress one. No request or response shape changed and no field was added or removed.","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\",\"country\":\"US\"}' | jq -c '{average_score, scores: [.matches[].score], impacts: [.matches[].dismissal_signals[].impact_on_score]}'","Assert that every number printed has at most one digit after the decimal point."],"expect":"average_score, every match score, and every impact_on_score carry at most one decimal place — for example 91.2 rather than 91.15384638309479, and -21 rather than -21.000000178813934","audit":"the rounded score is the value persisted with the screening record and the value compared against the alert threshold, so GET /v1/export?trace_id= returns the same figures the response carried and a reviewer reconciling an alert against its screening finds no discrepancy","regression":"TestRoundScore locks the reported precision against the observed float artifacts; TestRoundScoreThresholdDirectionIsFailSafe proves rounding can never move a score out of alert-firing range"}},{"date":"2026-09-12","category":"fixed","summary":"A match is no longer silently left unscored on an attribute you supplied — when the matched record carries no country or date of birth to compare against, the response now says so instead of saying nothing","details":"Screening compares a supplied country or date of birth against the matched record and reduces the score when they contradict, recording a dismissal_signals entry with the reason and the exact points deducted. When the matched record carried no value for that attribute, no comparison was possible and nothing was recorded — so a match that was never compared looked identical to one that was compared and agreed: full score, empty dismissal_signals. Attribute coverage is uneven across sources, so this was not rare, and it let an unpenalized match from a list carrying no countries outrank a penalized match for the same person with nothing in the response marking the difference. Such a match now carries a dismissal_signals entry with reason code country_not_comparable or dob_not_comparable, severity info, and impact_on_score 0. These record an evaluation outcome, not dismissal evidence: they report that the comparison could not be made, and must not be read as weak evidence in the match's favour. No score changes as a result of this, and the existing mismatch reason codes are unaffected. severity is a new value on an existing field and impact_on_score is always 0, so this is additive and 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\",\"country\":\"US\"}' | jq -c '[.matches[] | {source, score, signals: [.dismissal_signals[] | {reason_code, severity, impact_on_score}]}] | .[0:4]'","Assert the invariant across every match in that response: no match is missing a country signal. Each one either carries country_mismatch with a negative impact_on_score, or country_not_comparable with impact_on_score 0."],"expect":"every match carries a country dismissal_signals entry: records holding a contradicting country show reason_code \"country_mismatch\" with a negative impact_on_score, and records holding no country show reason_code \"country_not_comparable\" with severity \"info\" and impact_on_score 0. No match carries an empty dismissal_signals array for an attribute that was supplied","audit":"the persisted screening response (GET /v1/export?trace_id=) preserves every signal including the not-comparable ones, so a reviewer can establish from the stored record alone whether an attribute was evaluated and agreed or could not be evaluated at all","regression":"TestNotComparableSignal locks the shape and the 1.0 multiplier; TestApplyPenaltyNotComparableLeavesScore proves the score is untouched and legacy and structured reasons stay in lockstep"}},{"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-09-03","category":"changed","summary":"The CSV export now carries the jurisdiction advisory and the declared region, and the MCP screening tool surfaces an embargo nexus in its narrated result","details":"The CSV export projected the country a caller submitted but not the advisory it produced — the input rather than the finding, and the finding is the part that carries the obligation. Three columns are added: location_region (the sub-national region declared on the request), jurisdiction_risk (the jurisdiction code the advisory named), and jurisdiction_authority (its regulatory citation). They are blank for screenings with no nexus. The columns are appended within the existing row layout, so a consumer reading columns by NAME is unaffected; a consumer reading by fixed position must re-read the header row. The full JSON export is unchanged. Separately, the MCP screening tool now accepts region and city arguments so an agent can declare a sub-national nexus at all, keeps accepting a spelled-out country (\"Cuba\", \"IRAN\") alongside them rather than requiring an ISO code once a city is mentioned, and reports an embargo nexus in its summary line rather than only in the structured payload — an advisory on an otherwise clean name is exactly the finding a name-only reading of the result would miss. Batch results also gained a client-side shape, so a name screened in a batch and the same name screened singly no longer disagree about an embargo nexus depending on which surface was used.","endpoint":"/v1/export","verification":{"steps":["curl -s \"https://api.noblesight.io/v1/export/bulk?format=csv\u0026from=\u003c7 days ago, YYYY-MM-DD\u003e\u0026to=\u003ctoday, YYYY-MM-DD\u003e\u0026limit=1\" -H \"X-API-Key: $KEY\" | head -1 | tr ',' '\\n' | grep -n 'location_region\\|jurisdiction'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\",\"location\":{\"country\":\"UA\",\"region\":\"UA-40\"}}' | jq -r .trace_id","curl -s \"https://api.noblesight.io/v1/export?format=csv\u0026trace_id=\u003ctrace_id from step 2\u003e\" -H \"X-API-Key: $KEY\" | tail -1 | tr ',' '\\n' | sed -n '11,13p'"],"expect":"step 1 lists columns 11, 12 and 13 as location_region, jurisdiction_risk and jurisdiction_authority; step 3 prints those three fields for the screening from step 2 as UA-40, UA-40, and \"E.O. 13685; 31 CFR Part 589\". Note the header comes from /v1/export/bulk, which pages a date range — /v1/export returns a single screening and requires trace_id or request_id, so it cannot produce a bare header row.","audit":"the flat export now records the embargo determination alongside the input that produced it, so a CSV pull is sufficient evidence of what was found rather than only what was asked","regression":"TestExportCSVRowJurisdiction, TestLocationArgKeepsLiberalCountry, TestClientMirrorsAPIResponseTypes"}},{"date":"2026-09-03","category":"added","summary":"/status now reports an embargo_table component: the comprehensive-embargo jurisdiction set degrades visibly when it goes too long without being reconciled against published OFAC programs","details":"Every other component on /status watches a machine process — a feed stops arriving, a poll fails, the database stops answering. The comprehensive embargo set has no feed to watch: programs are established and revoked by executive order, so the set goes wrong when nobody reads the Federal Register, and that failure is silent by nature. /status now carries embargo_table, which reports degraded once the least recently reviewed entry passes the reconciliation window, and degrades the overall status with a message naming the cause. Screening is unaffected either way — a degraded embargo_table means the set may no longer match current law, not that screening has stopped. GET /v1/jurisdictions reports each entry's last review date so the specific overdue entry is identifiable. The check reads an in-process table, so it needs no database and keeps reporting when other components cannot.","endpoint":"/status","verification":{"steps":["curl -s https://api.noblesight.io/status | jq '{overall: .status, embargo: .components.embargo_table.status}'","curl -s https://api.noblesight.io/v1/jurisdictions -H \"X-API-Key: $KEY\" | jq '[.jurisdictions[].reviewed_at] | min'"],"expect":"step 1 returns an embargo_table status of \"operational\" or \"degraded\" (never absent); step 2 returns the oldest review date across the set, which is the date the component measures — if embargo_table is degraded, that date is older than the reconciliation window","audit":"the reconciliation date is published per jurisdiction, so a reviewer can establish when each determination was last checked against published programs rather than taking currency on trust","regression":"TestEmbargoTableComponent, TestEmbargoTableNotAlreadyOverdue, TestJurisdictionReviewAgeUsesOldest"}},{"date":"2026-09-03","category":"added","summary":"New GET /v1/jurisdictions returns the comprehensive-embargo record, queryable at a past date — so a jurisdiction that was embargoed and later revoked stays answerable","details":"Comprehensive embargo programs are established and revoked over time, and there was previously no way to ask what was embargoed when. GET /v1/jurisdictions returns every jurisdiction on record with its program, regulatory citation, the dates the embargo took effect and was revoked, and when the entry was last reconciled against OFAC's published programs; GET /v1/jurisdictions/{code} returns one, and accepts every token screening accepts — the canonical code in any case, plus region names such as CRIMEA, SEVASTOPOL and DPRK — so a token that raises an advisory always has a record to answer for it. Both accept as_of and report whether the embargo was in force at that instant, so a bank reviewing a historical transaction can establish the status that applied at the time. effective_from is the effective date of the instrument that ESTABLISHED the embargo — the executive order or proclamation, never the CFR part that later implemented it, which for Cuba are sixteen months apart — and determination names the instrument behind every date on the record, so no published date rests on nothing. Two properties are worth stating. The record reports the LAW, which is deliberately a different question from what a screening was told: a screening performed while the engine's table was behind a revocation keeps the advisory it was issued, and this endpoint reports the revocation — both answers are preserved rather than one being corrected to match the other. And jurisdictions that were evaluated and determined NOT to be comprehensively embargoed are published as explicit entries with the reason cited, because an examiner asking why a region is not flagged deserves a determination rather than silence. Requires an API key; available on every tier.","endpoint":"/v1/jurisdictions","verification":{"steps":["curl -s \"https://api.noblesight.io/v1/jurisdictions/SY?as_of=2024-01-01\" -H \"X-API-Key: $KEY\" | jq '{embargoed, effective_to}'","curl -s \"https://api.noblesight.io/v1/jurisdictions/CU?as_of=1962-06-01\" -H \"X-API-Key: $KEY\" | jq '{embargoed, effective_from, determination}'","curl -s \"https://api.noblesight.io/v1/jurisdictions/SY?as_of=2026-01-01\" -H \"X-API-Key: $KEY\" | jq '{embargoed, effective_to, determination}'","curl -s https://api.noblesight.io/v1/jurisdictions -H \"X-API-Key: $KEY\" | jq '[.jurisdictions[] | select(.embargoed)] | length'"],"expect":"step 1 returns embargoed true; step 2 returns embargoed true with effective_from \"1962-02-07\" and a determination citing Proclamation 3447; step 3 returns embargoed false with effective_to \"2025-07-01\" and a determination citing E.O. 14312; step 4 returns 7, the jurisdictions under a comprehensive embargo today","audit":"each entry carries the regulatory citation establishing its status and the date it was last reconciled against OFAC's published programs, so a historical status can be traced to the instrument that set it","regression":"TestJurisdictionsEndpointBothTimelines, TestJurisdictionsEndpointCollection, TestJurisdictionsEndpointAcceptsScreeningAliases, TestEveryPublishedDateCitesItsInstrument, TestCubaEmbargoRunsFromProclamation3447, TestEmbargoJurisdictionMatchesRegistry"}},{"date":"2026-09-03","category":"added","summary":"New optional location object on /v1/screen and /v1/batch declares a sub-national nexus, so a party in Crimea, Sevastopol, or the covered DNR and LNR regions now raises a jurisdiction advisory","details":"The comprehensive embargoes covering Crimea, Sevastopol and the so-called DNR and LNR regions apply to regions, not countries, and those regions have no ISO 3166-1 country code. Ukraine itself is not embargoed, so a party in Sevastopol could only be described as country \"UA\" — correct and insufficient — and no advisory could fire. Requests may now carry location: {country, region, city}, where region takes an ISO 3166-2 subdivision code (for example \"UA-40\") or a common region name; the same object is accepted per item on batch. Detection reads the region first, then the location's country, then the existing country field, stopping at the first that resolves to a jurisdiction on record — so {country: \"UA\", region: \"UA-40\"} reports Sevastopol rather than unembargoed Ukraine, and {country: \"IR\", region: \"IR-07\"} still reports the Iran embargo, because an ordinary province defers to its country instead of suppressing it. Supplying more precise geography never removes a signal. Two guarantees: location is evaluated for the advisory only and never affects match scores, so declaring a nexus can never penalize your own results; and location.country must be an ISO 3166-1 alpha-2 code, rejected with invalid_parameter naming the field rather than accepted and ignored. Region is deliberately not checked against the embargo set — an ordinary region such as \"California\" is recorded and simply returns no advisory, because failing a screening, or a ten-thousand-name batch job, over a supplementary signal would be the worse outcome. A subdivision code that contradicts the country declared alongside it is still refused. The existing country field is unchanged and keeps working exactly as before. A nexus that is not declared is still not detected — names are never parsed for region words. Additive — no version change.","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\":\"Zzyxx Cleanname\",\"location\":{\"country\":\"UA\",\"region\":\"UA-40\",\"city\":\"Sevastopol\"}}' | jq '{j: .jurisdiction_risk.jurisdiction, a: .jurisdiction_risk.authority, l: .jurisdiction_risk.level}'","curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\",\"location\":{\"country\":\"UA\"}}' | jq '.jurisdiction_risk'","curl -s -o /dev/null -w '%{http_code}\\n' -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\",\"location\":{\"country\":\"IRN\"}}'"],"expect":"step 1 returns jurisdiction \"UA-40\" citing E.O. 13685 at level \"comprehensive_embargo\"; step 2 returns null because Ukraine itself is not embargoed; step 3 returns 400 with code invalid_parameter and param \"location.country\", because \"IRN\" is not an ISO 3166-1 alpha-2 code","audit":"the declared location is persisted with the screening request in the immutable record, so the reviewer of a flagged screening can see the nexus that was disclosed rather than inferring it from the country alone","regression":"TestScreenLocationRegionNexus, TestScreenLocationRegionFallsThroughToCountry, TestScreenLocationRejectsContradiction, TestValidateLocation, TestNexusTokenPrecedence"}},{"date":"2026-09-03","category":"fixed","summary":"Replaying a past screening now reproduces the jurisdiction advisory exactly as it was issued, instead of recomputing it against today's embargo programs","details":"The jurisdiction advisory is derived from a table of comprehensive embargo programs that changes when programs are established or revoked. Replay previously recomputed the advisory from the current table, so a screening performed before a program change replayed with the current answer rather than the one it was given — the record appeared to disagree with itself, and a replay could report a mismatch for a screening that was in fact reproduced faithfully. Replay is now pinned to the instant of the original screening and returns the advisory that was actually issued then, including one issued under a program that has since been revoked. Re-reading a completed batch is pinned the same way, so an item renders what was recorded at the time it was screened. The distinction is between two different questions, and each surface now answers the one it is asked. Replay asks what you were told, so a screening from before the advisory existed replays with no advisory rather than a retroactive one, and a screening issued under a program since revoked replays with the advisory it was given. An as_of screening asks what was true of the world, so it reports the embargo that was legally in force on that date: screening an Iranian counterparty as of 2020 returns the 31 CFR Part 560 advisory, because Iran has been comprehensively embargoed since 1995 — the age of our own table is not part of that answer. Re-reading a completed batch answers the same question and is pinned the same way. Each table entry now records when its program took effect and when it was revoked, so a jurisdiction that was embargoed in the past is preserved as history rather than deleted, and a replay's field-by-field comparison now includes the advisory itself: a replay that disagreed with the record about an embargo previously still reported verdict \"match\". Live screening behaviour, request and response shapes are unchanged — no version change.","endpoint":"/v1/export/replay","verification":{"steps":["curl -s -X POST https://api.noblesight.io/v1/screen -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"name\":\"Zzyxx Cleanname\",\"country\":\"IR\"}' | jq '{trace: .trace_id, j: .jurisdiction_risk.jurisdiction, a: .jurisdiction_risk.authority}'","curl -s -X POST https://api.noblesight.io/v1/export/replay -H \"X-API-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"trace_id\":\"\u003ctrace_id from step 1\u003e\"}' | jq '{verdict: .verdict, j: .replayed.jurisdiction_risk.jurisdiction, a: .replayed.jurisdiction_risk.authority}'"],"expect":"step 1 returns jurisdiction \"IR\" with authority \"31 CFR Part 560\"; step 2 replays the same screening with verdict \"match\" and an identical jurisdiction and authority — the advisory is reproduced from the record, not recomputed","audit":"the advisory is written into the screening's immutable record at decision time, and replay resolves the embargo table as it stood at that instant, so a decision made under a program later revoked still reproduces as it was made","regression":"TestJurisdictionKnowledgeTimeline, TestJurisdictionLegalTimeline, TestJurisdictionRiskKnownAt, TestJurisdictionInForceIsNotFlooredAtShipDate, TestScreenAsOfEmbargoInForce, TestReplayEndpointPreservesLocationNexus, TestCompareReplayJurisdictionDrift, TestDetectJurisdictionZeroTimeMatchesLive"}},{"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"}],"entry_count":85,"fingerprint":"dbe61f04ca16c5eaa6fd1647584786c8aea7b1e1d7eba8dac183fcae8e7a7989","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."}}
