What changed, and why
The contact step has a name box with autocomplete. It was deliberately unfiltered — a raw Wikipedia title search — so it surfaced films, places and concepts ("Cleopatra (1963 film)", "Death of Cleopatra", "Mongolia") right alongside people. But the contact gate (#72 / #73) only reaches a real, deceased person, so previewing a non-person invited a pick that gets refused at send.
This PR adds a people filter. After the title search returns, one batched Wikidata wbgetentities call classifies the matches and keeps a result only when Wikidata calls it a person: **instance-of human (P31 → Q5), or it has a recorded death (P570)**. Everything else is dropped.
Blast radius is small and server-side: the logic lives in one util (server/utils/figure-search.ts); the API route and the picker component change only comments. The FigureSearchResult shape the client renders is untouched, so the combobox needs no changes. Crucially, the #58 rate-limit hardening on the search path is preserved — the filter is layered on top and degrades by falling open, never by blanking a working search.
The search body: filter, fall open, cache only when settled
searchFigures keeps its #58 shape exactly: a too-short query short-circuits, the cache is checked, the title search runs with one spaced retry, and a search that never answers returns { results: [], transient: true } so the combobox retries rather than reading weather as "no matches".
The new block is the people filter (lines 224–239). It only runs when the search returned results — an empty answer skips the lookup, so there's no wasted request. The interesting decision is the failure handling: if the filter lookup blips (people === null), the code does not blank the search. It sets filterTransient and falls open, returning the unfiltered matches with transient: false, because there genuinely are results to show. The cache write at the end fires only when neither stage was transient, so a fall-open is never cached and the next keystroke re-attempts the filter.
searchFigures: the search path is unchanged; the filter is the 224–239 block.
server/utils/figure-search.ts · 255 lines
⋯ 206 lines hidden (lines 1–206)
The predicate: human, or has a recorded death
isPerson is the heart of the change, and it's deliberately two clauses. A result is kept if its Wikidata entity is instance-of human (P31 → Q5), or it has a usable death date (P570). The death check reuses pickClaimYear from the grounding module, so "usable" means exactly what the dossier means: a non-deprecated claim with a real value snak that carries a time.
Q5 OR a usable P570 death — the people test.
server/utils/figure-search.ts · 255 lines
⋯ 179 lines hidden (lines 1–179)
⋯ 70 lines hidden (lines 186–255)
The full rationale — including that this is an honest near-superset of the gate, not a perfect one (a figure datable only by the AI bridge and not tagged Q5 won't be suggested) — lives in the module header, so the next reader inherits the reasoning rather than re-deriving it.
The module header: the people-filter rationale and its honest relationship to the gate.
server/utils/figure-search.ts · 255 lines
⋯ 225 lines hidden (lines 31–255)
One batched Wikidata call, matched back by title
fetchInstanceClaims is one attempt at the batched lookup. It asks wbgetentities for the result titles in a single request: props=claims carries P31/P570, and sitelinks + sitefilter=enwiki carry each entity's English title back so it can be matched to the search result it came from. normalize=1 is omitted on purpose — it's only valid for a single title, and this call batches several. A non-OK response or a throw returns null (the signal to fall open), never an exception.
The batched request. WIKI_HEADERS is the shared, policy-compliant User-Agent (#58).
server/utils/figure-search.ts · 255 lines
⋯ 158 lines hidden (lines 1–158)
⋯ 78 lines hidden (lines 178–255)
fetchPeopleTitles wraps that attempt in the same one-spaced-retry-then-give-up pattern the grounding lookup uses, then builds the set of person titles. Each entity is matched to its result through titleKey, which normalizes underscores, spacing and case on both sides — both are canonical enwiki titles, so this is belt-and-suspenders against cosmetic drift. A title with no Wikidata item, or one that isn't a person, simply never enters the set and is dropped.
titleKey (the matching key) and fetchPeopleTitles (retry, then collect person titles).
server/utils/figure-search.ts · 255 lines
⋯ 145 lines hidden (lines 1–145)
⋯ 38 lines hidden (lines 153–190)
⋯ 50 lines hidden (lines 206–255)
Tests: the cases that would catch a regression
The spec mocks fetch and routes by URL, so the search and the Wikidata filter are driven independently. Beyond the core "non-people are filtered" case, several tests exist specifically to pin behaviour a naive change would silently break.
The two predicate-defining cases: a non-Q5 figure with a death is KEPT; a fictional birth-only is DROPPED.
tests/unit/server/utils/figure-search.spec.ts · 449 lines
⋯ 161 lines hidden (lines 1–161)
⋯ 244 lines hidden (lines 206–449)
Two more guard the request contract and the resilience story. The contract test asserts the actual Wikidata URL carries props=claims|sitelinks, sitefilter and every result title — without it, dropping sitelinks would silently filter everything out yet still pass. The fall-open test drives a Wikidata failure and asserts the unfiltered matches come back, transient: false, and are not cached.
The request-contract assertion and the fall-open (no #58 regression) test.
tests/unit/server/utils/figure-search.spec.ts · 449 lines
⋯ 236 lines hidden (lines 1–236)
⋯ 121 lines hidden (lines 267–387)
⋯ 29 lines hidden (lines 421–449)
How it lines up with the contact gate
The gate this preview defers to is isDeceased in the grounding module: resolved && !!died, with no instance-of check. died can come from Wikidata P570 or the #31 AI bridge. So the filter keeps everyone the gate reaches via a Wikidata death (they have P570), plus all humans — looser than the gate on living people (by design; the dossier explains the floor), and with one narrow under-coverage gap (AI-bridge-only, non-Q5) the header calls out honestly.
The gate the search preview is reasoning about — death is its floor.