Skip to main content

Sep 3, 2026 — Release Notes

Period: Wednesday September 3, 2026 Merged: 5 pull requests — #1236, #1233, #1235, #1241, #1238 Migration head: 110_case_archive


The theme: two controls that looked healthy while not working

Three of the five changes are ordinary hygiene. Two are not, and they share a shape this project has a name for — a control reporting its shape rather than its state.

  • #1241 — sanctions screening kept answering, from a corpus that had not refreshed for 18 days. Nothing was broken in a way any surface showed.
  • #1235 — a compliance verdict that had been computed was reported to the officer as one that could not be made.

Neither was found by a gate. Both are recorded below with the mechanism that now catches them, because the fix is the smaller half.


#1241 — Screening answered from a corpus that had silently aged

What happened

https://data.opensanctions.org/datasets/latest/<dataset>/targets.simple.csv is now served as an HTTP 307 to a dated artifact path (measured 2026-09-03: …/artifacts/sanctions/20260903074701-oim/targets.simple.csv). httpx does not follow redirects by default, so raise_for_status() raised on every dataset and every refresh failed.

Why it was expensive

Screening never stopped working. It answers from the stored corpus, and the pilot holds ~907K entities — which looks entirely healthy from every surface an officer or an operator can see. Underneath, opensanctions_metadata recorded the last successful refresh as:

DatasetLast successful refreshStaleness when foundConfigured cadence
sanctions2026-08-1618 days24 h
peps2026-08-1123 days168 h
wanted2026-08-1618 days24 h

A person designated inside that window is not in the local list at all, so a screen against them returns a clean result. That is the false clear ADR-0067 exists to prevent, reached through stale data rather than through a skipped check — the check ran, the check was current in its own terms, and the corpus it consulted was not.

It compounds: ADR-0128's immediate-on-designation re-screen reads its delta from this same refresh, so the control built to catch exactly this class was disarmed by the same failure.

Detection existed and did nothing

The loader logged Failed to load OpenSanctions dataset sanctions: Redirect response '307' on every boot — into a log nobody reads. That is the same shape recorded in ADR-0180 (an empty sweep and a sweep that never ran were indistinguishable) and in the backup detector that logged an outage 548 times. The gap between a detector firing and anyone being told is filed separately; this change fixes the fetch.

The fix, and the guard

follow_redirects=True on the loader's httpx.AsyncClient. The guard is in two halves because neither is sufficient alone:

  1. a transport test drives a real 307 through httpx with the loader's own kwargs, plus a control asserting the no-flag client genuinely fails on that shape — so the test cannot pass against a source that never redirects;
  2. a source assertion binds the behaviour to the shipped call site, since a transport test alone cannot show the deployed client is the configured one.

Mutation-verified: removing the flag fails the binding test while both transport tests still pass, which is the correct split.

See Sanctions Screening → Corpus currency.


#1235 — A computed verdict reported as "not assessed"

GET /api/cases/{workflow_id}/rule-evaluations opened its RLS session with user.tenant_id — the caller's home tenant from the JWT — instead of the get_current_tenant dependency that honours the super-admin X-Tenant-Id header (ADR-0081). The frontend client already stamped that header; the endpoint ignored it.

For a viewer whose home tenant differs from the case's, the workflow_id → case_id lookup inside that session returned no row and the endpoint took its early return, which carries no verdict key. One branch produced both observed UI strings at once: evaluated: false rendered "No reasoning template evaluation is persisted", and the absent verdict rendered the fail-closed "Verdict unavailable" banner.

Measured against a live database — same case, same instant, varying only the session tenant: under the viewer's home tenant the lookup returns None; under the case's own tenant it resolves and get_case_compliance_verdict returns escalate, with 2 critical / 7 high findings, a fired ENTITY_CRIMINAL_INVESTIGATION escalator, outstanding sanctions review and a PEP hit.

This is not a false clear — the banner correctly refused to read as clear, so ADR-0067's headline invariant held. It is a false "not assessed", which the same doctrine forbids in the other direction: a determination that was made must not be presented as absent. Recording it that way matters, because the two failures have opposite remedies.

risk_config.py already carried a fixed instance of this identical defect with a docstring describing the same failure mode on a sibling endpoint. The class is known and is being repaired one endpoint at a time; the remaining sites are tracked in #1234 rather than swept here.

Guard: the test pins the tenant the endpoint opens its session with, not the response body — a body assertion would pass for the wrong reason as soon as a stub resolved the case regardless of tenant. Mutation-tested; a vacuous-pass probe asserts the capturing seam was actually entered.


#1233 — Every API redirect was http from an https page

Reported: /admin/prompts showed "Could not reach the service: prompt templates. The request did not complete, so this list is unknown — not empty."

Measured:

GET https://kyb.savannah-ai.com/api/admin/prompts
-> HTTP 307
location: http://kyb.savannah-ai.com/api/admin/prompts/

An http redirect issued to an https page. The browser refuses it as mixed content, so the request never completes.

FastAPI's trailing-slash redirect builds an absolute URL from the scheme it believes it was reached on. nginx sets X-Forwarded-Proto: https, but uvicorn ignores that header unless started with --proxy-headers, which it was not. This is a class, not an instance — it affected every redirect the API emits; /admin/prompts is merely where it was noticed.

Worth recording: curl hid it. curl follows the redirect and lands on a 401, which reads as an ordinary authentication failure. The defect lived only in the location header, never in the final status — the diagnosing tool was blind to the rule that broke the diagnosed tool.

The product reported this honestly. "The request did not complete, so this list is unknown — not empty" is the typed-absence pattern working as designed; without it the pane would have rendered an empty list and read as "there are no prompt templates".

The allow-list is the half that is easy to get wrong

--proxy-headers alone trusts forwarded headers only from 127.0.0.1, and nginx reaches the app from another address on the Docker network. The first commit therefore set --forwarded-allow-ips "*" and justified it in a comment as "safe here because the container publishes to loopback only".

docker-compose.yml published "8002:8002", which binds every host interface. The justification was false as written — the claim-vs-check defect, written into the fix for a different one. Codex flagged it P1 and was correct.

The consequence is not theoretical: under "*", uvicorn trusts any caller's X-Forwarded-For and replaces request.client with it, and rate_limiter.resolve_rate_limit_identity keys the anonymous bucket on exactly that value. A caller able to reach the port rotates one header and gets an unlimited anonymous allowance; portal audit events would also have recorded the forged address.

Measuring narrowed the blast radius without excusing it: the pilot does not publish that port at all, so the live system was never exposed. The repository is what a follower deploys, so the repository is what has to be safe.

Two independent changes, either of which would close it:

  • docker-compose.yml binds 127.0.0.1:8002:8002;
  • the allow-list names loopback plus the RFC 1918 ranges a Docker bridge or a same-host proxy actually originates from — 127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16. uvicorn 0.49's _TrustedHosts accepts CIDR; that was read in the installed package, not assumed.

The failure direction is deliberate: an external client's forwarded headers are now ignored, so the worst case is the http-redirect defect returning — visible, and safe — rather than a spoofable identity, which is neither.

Why the guard took two attempts. The first regex required = or whitespace after the flag, which matches the shell form in docker-compose.yml and not the JSON-array form in the Dockerfile, where the separator is ", . Its positive control tested only shell forms, so it passed — and a mutation putting "*" back into the Dockerfile survived. A control that omits the shape the repository actually uses certifies nothing. The pattern is now defined once and shared by the guard and its control; both mutations are caught.


#1238 — Archive a case out of the queue

delete_case returns 409 for any case carrying audit history — an ON DELETE RESTRICT foreign key from audit_events enforces AMLR Art. 77 retention at the database. That is correct and is unchanged. In practice it is every case that has ever run, so an officer had no way to clear a test case, an abandoned case, or a near-duplicate of a different entity out of the queue. Worse, the 409's own advice named a soft-delete and a close endpoint that do not exist — the claim-vs-check shape inside the error message a blocked user reads. That message now names the archive endpoint.

ADR-0114 already solved the same-entity duplicate, but supersede_case is fail-closed on same entity (normalised registration number + country), so it can hide a second OB Holding 1 OÜ and cannot hide Olybet Srl or a case created by mistake — there is no canonical twin to point at. This is ADR-0114's shape with a looser predicate, and deliberately nothing more.

View-only is the whole contract:

  • status is never mutated and the Temporal workflow keeps running — the same decision ADR-0114 made, for the same reason: status feeds the entity-disposition and decision gates, which must be provably unaffected by a queue affordance.
  • Ongoing monitoring (Art. 21), the retention clock (Art. 77), the regulator case pack and the compliance verdict are all untouched. Archiving reduces what one officer sees, never the scrutiny applied to a customer relationship that legally still exists.
  • The hidden count is disclosed on the same surface that hides it — archived_count rides beside superseded_count whether or not the toggle is on. That disclosure is the control; there is no new gate.

POST /api/cases/{workflow_id}/archive and /unarchive require CASE_DECIDE, demand a rationale (aliased to supersede's validator so the bar cannot drift between two affordances that do the same thing), refuse a double-archive with 409, and write immutable hash-chained case_archived / case_unarchived events before the commit — a committed audit row for a failed UPDATE is acceptable over-recording; a committed UPDATE with no audit row is not.

Migration 110_case_archive adds three nullable columns and a partial index on archived_at IS NOT NULL: the queue's default predicate is IS NULL, the overwhelming majority, so indexing the archived minority is what makes the count cheap without paying for an index the common path never uses. Additive — every existing row reads as not-archived, no backfill.

The two exclusions compose: a case hidden for both reasons needs both toggles, which is the honest reading of two independent reasons to hide it.

See Cases API → Archive Case.


#1236 — Dependency advisories that blocked every PR

Both dependency gates went red on master's own lockfiles with no dependency change — advisories published against unchanged packages after the last green run on 2026-08-31.

EcosystemPackageBefore → afterAdvisories
npmbrowserslist4.28.2 → 4.28.8GHSA-c83g-rgw3-j3cx (unbounded memory growth → OOM), GHSA-73wf-gq98-2v4g (crash / prototype write)
npmfast-uri3.1.5 → 3.1.7GHSA-5jgf-p345-68v8, GHSA-f65p-4m7j-42xc, GHSA-fph4-wmhf-6fwf, GHSA-jqff-g426-hqxp (host confusion / SSRF)
Pythonnltk3.10.2 → 3.10.322 → 1
Pythonpypdf6.15.0 → 6.16.23 → 0

The npm side is lockfile-only (npm audit fix --package-lock-only); package.json is untouched because every bump lands inside an already-declared range, so no dependency contract moves. After: high/critical = 0. The 5 low + 9 moderate are unchanged and remain outside the gate's threshold.

The Python lock was regenerated with backend/scripts/compile_requirements.sh (ADR-0177), not a bare uv pip compile — 263 pins before and after, exactly 2 changed. nltk is transitive (via crawl4ai) and imported nowhere in app/, but it is now declared in both requirements.in and pyproject.toml, because the parity suite refuses a runtime dependency the image installs that pyproject does not declare: pip install -e . would otherwise get it only by accident. pypdf is ours and is called in three places that parse third-party PDFs (trust_capsule_service, cz_financials_extractor, lex/fetchers/pdf_fetcher), so those CVEs sat on a path actually executed.

One advisory has no fix and is recorded as an exception, not suppressed. PYSEC-2026-3740 (= CVE-2026-81726 = GHSA-8mgp-746c-j5xp) against nltk has no fixed release. It is carried as a fourth --ignore-vuln entry in the required pip-audit (Python deps) — blocks gate and in CLAUDE.md's mandatory list, with the reasoning — including the fact that the auto-generated PyPI record and the CVE disagree about whether 3.10.3 fixes it — written up in docs/security/dependency-exceptions.md §9.

Nothing here was cleared with an ignore list for the fixable advisories: the artifact changed, so the gate goes green because the vulnerable bytes are gone (ADR-0157 / ADR-0164 precedent).


  • Sanctions Screening — corpus currency and the screening pipeline
  • Security — tenant scoping and the reverse-proxy trust boundary
  • Cases API — the archive/unarchive endpoints
  • Deployment — how the app sits behind a TLS-terminating proxy