How Company Search Works
This document explains how Company Search (a.k.a. "Spotlight Search") is implemented across the frontend and backend, including the data flow, hybrid registry+agent search, ranking/dedup behaviour, caching, and known limitations.
Organisation search runs three channels, not one: an internal Neo4j quick-lookup (instant, DB-only), a corporate-registry lookup (ABR for Australia, OpenCorporates for every other country including Great Britain and Global), and an AI web-search agent (Gemini 3.5 Flash via the Vercel AI Gateway, single Tavily web_search tool). Selecting a candidate fires an Inngest-orchestrated enrichment pipeline (organisation.enrich); a candidate with a resolved website enriches directly via CompanyEnrich, everything else falls back to the same agentic pipeline. Diffbot is not used anywhere in this flow (it remains in the Issues Agent article-extraction path only).
Table of Contents
- High-Level Architecture
- Frontend
- Backend
- Database Layer
- Caching
- Validation and Rate Limiting
- Feature Flags
- Data Types
- Known Limitations and Dead Code
High-Level Architecture
┌────────────────────────────────────────────────────────────────────────────────┐
│ Browser — OrganisationSearch │
│ │
│ User types, then presses Enter or clicks the search button │
│ (manual trigger — no debounce, no search-on-keystroke) │
│ │ │
│ ├──► POST /api/organisations/quick-search (internal only, JSON) │
│ │ └─► findByFuzzyName (+ findByIdentifiers / findByWebsite │
│ │ when the query looks like an identifier / URL) │
│ │ country-scoped unless Global; batched accountLinked stamp │
│ │ → instant candidates, source: 'internal', _type: 'internal' │
│ │ │
│ └──► POST /api/organisations/search (streamed NDJSON, cancellable) │
│ └─► SearchOrganisations.executeStream (mode: 'auto' by default) │
│ ├─► registry channel (country === 'AUS' ? ABR : OpenCorporates)│
│ │ up to REGISTRY_LEAD_MS (5s) head start over the agent │
│ └─► agent channel: searchOrganisationsAgent │
│ (Gemini 3.5 Flash via Vercel AI Gateway, │
│ single Tavily `web_search` tool) │
│ per candidate: composeOrganisationKey → findAllByKeyForSearch │
│ → stamps existingOrgId on a match (source stays the │
│ 'agent'|'abr'|'oc' discovery channel); dedup by │
│ identical-or-empty identifier sets; late registry │
│ hits emit identifierPatch items that upgrade rows │
│ │
│ Quick + registry + agent candidates merge by composite `key` (quick first). │
│ Client-side All / New / Existing scope filters on `accountLinked`. │
│ │
│ On user selection: │
│ selectOrganisationAction → MaterialiseOrganisation (create-if-missing) │
│ → mark profile PENDING → Inngest `organisation.enrich` │
│ → website present? direct CompanyEnrich enrichment : agentic pipeline │
│ → navigate to buildSelectionDestination(intent, organisationId) │
└────────────────────────────────────────────────────────────────────────────────┘The internal quick-lookup and the registry+agent search run in parallel from the client; the external request is cancellable, so a newer query (or cancelSearch()) immediately aborts in-flight work via AbortController → request.signal, propagated all the way down into the ABR/OpenCorporates/Tavily HTTP calls.
Frontend
Routes and Slots
Company search is split across two Next.js App Router parallel-route slots, both of which react to the same search-mode state:
@search(app/(main)/@search/) — supplies the search surface (the black search bar + results). Passed as thesearchprop into(main)/layout.tsx, which renders it throughSearchModeLayout.@header(app/@header/, root level) — supplies the top nav bar. Passed as theheaderprop into the rootapp/layout.tsx. It swaps the normal lightMainHeaderfor the darkSearchModeHeaderwhile search is active.
A slot adds no URL segment. For every route the layout can render, the slot resolves to the most specific matching page.tsx, falling back to a [...slug] catch-all, then default.tsx.
@search slot
| Route | Resolves to | Renders |
|---|---|---|
/dashboard | @search/dashboard/page.tsx (DashboardSearchSlot) | OrganisationSearch — unless the commandKSearch flag is on, where it returns null and the global SearchOverlay owns search |
/engage | @search/engage/page.tsx (EngageSearchSlot) | OrganisationSearch wrapped in EngageSearchWrapper |
every other (main) route (/assess, /identify, /reports, /organisations/..., …) | @search/[...slug]/page.tsx | null |
| unmatched / hard-refresh fallback | @search/default.tsx | null |
There is no
@search/assess/page.tsx. On/assessthe search slot rendersnullvia the catch-all. The dark "Company Search" header seen on/assesscomes from the@headerslot, not the search slot (see below).
Why both [...slug]/page.tsx and default.tsx (and both return null)
This is the subtle part. On a soft (client-side) navigation, if a slot has no matching page Next.js keeps the slot's previously-rendered subtree — so without intervention, navigating /dashboard → /assess would leave the dashboard's OrganisationSearch mounted on /assess.
- The
[...slug]catch-all defeats this: it actively matches every non-search route and rendersnull, so the previousOrganisationSearchunmounts on navigation. default.tsxis Next's required fallback for slot states it can't match from the URL (notably a hard refresh on a deep route); without it those cases 404.
A static segment (dashboard, engage) always beats the dynamic catch-all, so the explicit search pages win where they exist; everything else drops to null. (The /search route is a normal page that redirects to /dashboard; it is not a @search slot file.)
@header slot (the search-mode header)
| Route | Resolves to | Header |
|---|---|---|
/dashboard | @header/dashboard/page.tsx | SearchAwareHeader (no label) |
/assess | @header/assess/page.tsx | SearchModeHeader directly when ?module=spotlight|analyst; otherwise SearchAwareHeader (label "Company Search") |
| every other route | @header/default.tsx | MainHeader |
SearchAwareHeader reads SearchModeContext and renders the dark SearchModeHeader (back-arrow + X) when search is active, otherwise the normal MainHeader. (In ⌘K mode the header always stays as MainHeader; the search overlay floats on top instead.)
Component Hierarchy
Components live under @search/_components/ and @search/_lib/ (moved under the _ prefix so the App Router treats them as private, non-routable):
Root layout (header, children, upload)
├── @header slot → SearchAwareHeader → MainHeader | SearchModeHeader (dark; back-arrow + X)
└── (main) layout (children, search)
└── SearchModeLayout ← reads inSearchMode; shows search slot vs. page children
├── @search slot
│ └── DashboardSearchSlot (or EngageSearchSlot / EngageSearchWrapper)
│ └── OrganisationSearch (_components/organisation-search.tsx)
│ ├── SpotlightStepHeader (step dots + "Search by company name")
│ ├── Toolbar
│ │ ├── SpotlightIcon
│ │ ├── <input> (organisation query)
│ │ ├── Dropdown (country, incl. Global)
│ │ ├── focusPanel: SectionToggleTabRoot (All / New / Existing)
│ │ │ + Business names / Sole traders Switches (AUS only)
│ │ └── search / cancel button
│ ├── ToolbarResults → CandidateResultItem[] (results)
│ ├── ToolbarResultsEmpty (no matches / error states)
│ └── SpotlightSearchPreview | previewSlot (when no results yet)
└── children (hidden while inSearchMode)_lib/ holds the client-only logic: use-organisation-search.ts (a standalone hook implementing the same state machine — the main OrganisationSearch component does not use it, it reimplements the state machine inline with the scope/toggle logic layered on top; the hook instead powers ToolbarSpotlightSearch on the /engage sticky header, a narrower search surface without the focus panel), organisation-search-merge.ts (applyStreamItem, mergeCandidates, deriveNoResults), and organisation-search-types.ts (the Candidate discriminated union).
SearchModeContext controls visibility across both slots: when isSearchActive || hasSearchContent, SearchModeLayout hides the page children and shows the @search slot, and SearchAwareHeader swaps MainHeader for the dark SearchModeHeader. See Search Mode State & Navigation for how this state is set and cleared.
Search Mode State & Navigation
The on/off state for search lives in SearchModeContext (components/shared/search/search-mode-context.tsx), provided by SearchModeProvider at the app root (app/providers.tsx). Two flags drive everything:
isSearchActive— set when the input is focused, or when a launcher arrives with asearchIntentparam in the URL (e.g. the Spotlight/Analyst cards on/assessnavigate to/dashboard?searchIntent=analyst&returnTo=%2Fassess).hasSearchContent— set when the query is non-empty or a search has run.
inSearchMode = isSearchActive || hasSearchContent is the single signal read by both slots.
Because the provider sits above the slots, this state survives client-side navigation — it is not derived from the URL. It is cleared by closeSearch(), which runs registered cleanups and resets both flags. closeSearch() is called by:
- the X / back-arrow in the dark header (the back-arrow goes through
useCloseSearchNavigation, which callscloseSearch()before navigating), and - a result selection (after navigating to the chosen organisation).
Browser back/forward
The browser's native back/forward buttons perform a soft navigation that does not go through closeSearch(). To keep the global state from outliving the search surface, OrganisationSearch resets its flags on unmount (the [...slug] catch-all unmounts it when you leave /dashboard):
hasSearchContentresets on unmount via its effect cleanup.isSearchActiveresets on unmount via a dedicated effect:useEffect(() => () => setSearchActive(false), [setSearchActive]).
Without the isSearchActive reset, pressing the browser back button from a launched search (/dashboard?searchIntent=analyst&returnTo=%2Fassess) back to /assess left isSearchActive stuck true — so /assess rendered the dark SearchModeHeader with its page content hidden (an empty "stuck in search mode" view). The app's own back-arrow avoided this only because it routes through closeSearch(). The unmount reset realigns the state lifecycle with the slot lifecycle, so history navigation behaves the same as the app controls.
Deeper trade-off: search-mode is imperative global state, manually synced with the app's own navigations, rather than derived from the URL (which is what browser history actually moves through). The unmount reset closes this specific leak; deriving
isSearchActivefrom the route would remove the class of bug entirely, but is a larger change.
Focus Panel: Scope Selector and AUS Toggles
File: apps/web/src/app/(main)/@search/_components/organisation-search.tsx
When the hybridSearch feature flag is on, Toolbar renders a focusPanel — an overlay beneath the search bar containing:
- All / New / Existing scope selector (
SectionToggleTabRoot/SectionToggleTabList/SectionToggleTab,useState<'all' | 'new' | 'existing'>('all')). This is a client-side filter, not a separate query: auseMemomerges the quick and registry/agent candidates (mergeCandidates) and then filters onaccountLinked—newkeeps unlinked candidates,existingkeeps linked ones,allpasses the merge through untouched. The one exception isexisting, which also skips firing the external registry+agent request entirely — there's no point paying for a web/registry search when only account-owned matches will be shown. - Business names toggle (
includeBusinessNames, defaultfalse) and Sole traders toggle (includeSoleTraders, defaultfalse) — rendered only whencountry === 'AUS', anddisabledwhilescope === 'existing'. Toggling Business names switches the ABR request preset from'business-name-aware'(default — legal entities, discoverable via a matched business name, collapsed to one row per ABN) to'with-business-names'(business names surface as their own uncollapsed rows). Toggling Sole traders setsincludeSoleTraders: true, widening the ABR entity-type allowlist to includeIND(Individual/Sole Trader).
Both toggles are computed as effective* = country === 'AUS' && rawState, and a useEffect resets the raw switch state to false whenever the country leaves 'AUS' — so returning to AUS later shows both switches off rather than silently reviving a stale on-state from before the country change.
Refetch semantics are intentionally asymmetric: a pure All↔New scope change does not refetch (both scopes fetch the identical request and differ only in the client-side filter — refetching would be a wasted round-trip and a visible flash). A change to country, either AUS toggle's effective value, or crossing the existing boundary does refetch, tracked by comparing against a prevFetchInputsRef snapshot kept in sync by an effect keyed to those same four inputs.
When hybridSearch is off, focusPanel/focusPanelOpen are both undefined (no panel renders at all), and the server independently pins external search to agentic-only (see Feature Flags) — the two are enforced at different layers so a client that never learned about the flag can't reach the registry.
Country Picker and Global Scope
The country Dropdown pins TOP_COUNTRY_CODES = [GLOBAL_SEARCH_SCOPE, 'AUS', 'NZL', 'CAN', 'CHN', 'GBR', 'USA'] above a divider, with the remaining countries alphabetically below. GLOBAL_SEARCH_SCOPE (the 'GLO' sentinel, exported from @repo/core) renders as "Global" in the picker (getCountryOptions(true) in apps/web/src/lib/country-options.ts prepends it when includeGlobal is passed).
Selecting Global removes the country constraint from all three search channels — see Global (GLO) Scope for how the backend resolves a real per-candidate country and keeps the GLO sentinel from ever reaching a persisted record.
Search Endpoints and Actions
Searching is done by two API routes (not server actions); the server actions handle selection / enrichment.
| Entry point | Type | Purpose |
|---|---|---|
POST /api/organisations/quick-search | API route | Fast internal-only lookup. Always runs findByFuzzyName; additionally runs findByIdentifiers / findByWebsite when the query shape matches. Country-scoped unless Global. Returns { candidates }, all source: 'internal', _type: 'internal'. |
POST /api/organisations/search | API route | Registry + agent search. Streams candidate / identifierPatch / channel-degraded items as NDJSON from SearchOrganisations.executeStream; cancellable via request.signal; responds with Cache-Control: no-store; rate-limited. |
selectOrganisationAction | server action | User picks a candidate. Short-circuits to the existing id when the candidate already maps to an internal org; otherwise MaterialiseOrganisation (create-if-missing by composite key) → mark profile PENDING → fire Inngest organisation.enrich. |
triggerEnrichmentAction | server action | Re-runs the enrichment orchestrator for an existing organisation. |
Both API routes validate input with Zod and require an active account via requireAccount()/requireAccountOr401() (returning 401 on failure). Both server actions also call requireAccount().
SSR (Server-Side Rendering)
The dashboard search slot is a server component but runs no server-side search. DashboardSearchSlot reads the ?q and ?country search params and passes them as initialQuery / initialCountry props to the client OrganisationSearch. All searching (quick + registry + agent) happens on the client after hydration; a deep-linked ?q= therefore pre-fills the input but does not auto-run until the user triggers it.
Result Display
Results render as CandidateResultItem buttons inside ToolbarResults. Each item shows:
- Company name (
canonicalName) - "Existing" badge — shown to all users (not dev-mode-gated) whenever
candidate.accountLinkedis true, distinct from the dev-modeexisting/newtag described below. - "Trading as: X" / "Trading entity of: X" subtitle, derived from
matchedBusinessName/parentEntityName(mutually exclusive) — surfaces when an ABRbusiness-name-awaresearch matched via a registered business name rather than the legal entity name. - Country code with flag (via
toAlpha2+ flag-icon CSS) - Website domain, when present (
displayDomain) - Description text
The company logo is a computed Brandfetch URL derived from the org's website (see buildBrandfetchLogoUrl). Because the URL requests a lettermark fallback, any org with a website always renders at least a generated letter tile rather than a broken image; the logo is absent only when the org has no website (or BRANDFETCH_CLIENT_ID is unset). In dev mode each item also shows an existing / new tag, source, the raw composite key, and (ABR candidates only) a relevance score; these are hidden in production.
Selection. Clicking a result calls selectOrganisationAction, then navigates via buildSelectionDestination(intent, organisationId):
searchIntent | Destination |
|---|---|
spotlight | /organisations/{id}/spotlight |
analyst | /organisations/{id}/analyst |
engage | /organisations/{id}/engage |
| (none) | /organisations/{id}?from=search |
A newly created organisation (data.created) goes through buildSelectionDestination (the "Is this the company you were looking for?" confirm header); selecting an existing organisation skips that confirm flow via buildSearchIntentDestination — the user already knows the company. A rejected selection (e.g. a schema validation failure) surfaces an inline error tile rather than failing silently.
URL Parameters
| Parameter | Purpose |
|---|---|
q | Search query. Written to the URL when a search runs; seeds initialQuery on load. |
country | ISO 3166-1 alpha-3 country filter, or GLO for Global. Written alongside q; seeds initialCountry. |
searchIntent | Destination intent set by a launcher (spotlight / analyst / engage); read on selection to pick the destination. |
returnTo | Back-navigation target carried with searchIntent; both are stripped when search is closed without selecting. |
from | Set to search on the default (no-intent) selection path; marks the confirmation flow on the destination page. |
(focus and module are not part of the search flow; module belongs to the assess pages.)
Backend
Internal Quick-Lookup (Neo4j)
The /api/organisations/quick-search route runs up to three internal lookups in parallel and returns instant source: 'internal' candidates before the registry/agent search completes:
| Lookup | When | Behaviour |
|---|---|---|
OrganisationRepository.findByFuzzyName({ query, accountId, countryIsoAlpha3?, limit }) | always | Fuzzy match on name via the organisation_name_fulltext index, scored with APOC Jaro-Winkler distance. Returns [] for empty/blank queries. |
OrganisationRepository.findByIdentifiers([query], accountId, countryIsoAlpha3?) | query looks like an identifier (contains a digit) | Normalised identifier-value match (tolerates case/spaces/dashes/dots). |
OrganisationRepository.findByWebsite(query, accountId, countryIsoAlpha3?) | query looks like a URL/domain | Normalised website match (tolerates protocol / leading www. / trailing slash). |
Country scoping: countryFilter = countryIsoAlpha3 === GLOBAL_SEARCH_SCOPE ? undefined : countryIsoAlpha3 — under a specific country, all three lookups are scoped to it (a change from the prior country-agnostic quick-search); under Global, no country filter is applied at all.
Dedup and ownership: results are de-duplicated by a compound <key>#<orgId>, not the bare composite key — two distinct orgs can legitimately share a composite key (a global verified org plus the caller's own private duplicate of the same entity). When the caller owns a row sharing a base key, any globally-visible row sharing that same base key is dropped in favour of the owned one.
accountLinked stamping: after collecting candidates, one batched findExistingOrgIdsForAccount call (not per-candidate) resolves which existingOrgIds belong to the caller's account. On failure this degrades rather than failing the whole request — accountLinked: false for every candidate — since it's an enrichment of an already-successful lookup, not the lookup itself.
Error handling: the three internal lookups are wrapped in a try/catch; on failure the route logs and returns a genuine 503 with { candidates: [] } (not a silently-empty 200).
GLO leak-guard: a candidate's countryIsoAlpha3 is stamped from org.country?.[0]?.code ?? countryFilter ?? '' — falling back to the filter (undefined under Global), never the raw 'GLO' input — so a genuinely countryless org lands on '', and the 'GLO' sentinel itself never reaches a candidate row (see Global (GLO) Scope).
Registry + Agent Search
/api/organisations/search streams from SearchOrganisations.executeStream (packages/core/src/application/use-cases/organisation/search-organisations.ts), which runs up to two channels concurrently and layers deterministic post-processing on top.
SearchMode = 'agentic' | 'auto' | 'direct':
| Mode | Behaviour |
|---|---|
agentic | AI web-search agent only. No registry call; abrPreset/includeSoleTraders are ignored. |
auto | Production default. Registry and agent run concurrently. |
direct | Registry only, no AI. A degraded registry channel fails the whole stream (there's no fallback channel). |
The client always requests auto; the server downgrades it to agentic when the hybridSearch flag is off, regardless of what the client sends (see Feature Flags).
Registry lead time: in auto mode, the registry batch gets up to REGISTRY_LEAD_MS (5,000ms) to resolve before the agent begins streaming, so government-verified identifiers win key-dedup over the agent's identifier-less guesses. If the registry is slower than that, the agent streams immediately and the registry result is folded in afterward as SearchOrganisationsIdentifierPatch (_type: 'identifierPatch') items — these upgrade an already-rendered candidate in place, including flipping a "new" candidate to "Existing" if the late registry hit also resolves to an internal DB row.
Key derivation and internal resolution: for each raw candidate, composeOrganisationKey({ countryIsoAlpha3, canonicalName }) builds the composite key <ISO_ALPHA3>|<lowercased canonical name>, then OrganisationRepository.findAllByKeyForSearch(key, accountId) resolves any internal DB row(s) sharing that key (applying the same owned-over-global preference described for quick-search) and stamps existingOrgId on the candidate when a match is found. source itself is not an internal/web discriminant here — SearchCandidateSource = 'agent' | 'abr' | 'oc' instead records which discovery channel surfaced the candidate; whether it also matched an internal org is signalled independently by existingOrgId being non-null. (The client-side InternalQuickSearchCandidate type does have a literal source: 'internal' — but that's the separate quick-search candidate shape described in Data Types, not this pipeline.) Key derivation is a pure, deterministic regex-based canonicaliser (DeriveOrganisationKey/canonicaliseOrganisationName) — there is no LLM and no Redis cache in this step (an earlier revision used an LLM canonicaliser with a Redis cache; that was replaced with a deterministic sanitiser and the cache became unnecessary).
Country filter: outside of Global scope, a candidate whose resolved country doesn't match the requested country is dropped. The agent's own instructions also hard-constrain it to the selected country; the use case re-checks regardless, so the model can never widen visibility.
Channel failure surfacing: a SearchOrganisationsChannelDegraded ({ _type: 'channel-degraded', channel: 'registry' }) item is emitted when the registry channel genuinely fails (HTTP error, timeout, missing config) as opposed to a clean zero-result search. The client's deriveNoResults predicate treats a degraded registry with zero candidates as "results may be incomplete," never as "No matches found" — collapsing the two would look exactly like the company doesn't exist and could push a user toward creating a duplicate org for a supplier that does exist.
accountLinked is a first-class field on every streamed candidate, resolved per-candidate during streaming (resolveAccountLinked, fast-pathing on ownedBy else checking account membership) and, for non-streaming callers (execute(), used by tests/batch jobs), resolved once via a single batched findExistingOrgIdsForAccount call after collection instead of per-candidate.
ABR: Business Names and Sole Traders
Files: packages/core/src/infrastructure/repositories/abr-repository.ts, abr-config.ts
ABR search is driven by a named preset (ABRSearchPreset = 'entity-only' | 'business-name-aware' | 'with-business-names', resolved in abr-config.ts's resolveSearchOptions) plus an orthogonal includeSoleTraders: boolean flag that widens rather than replaces the preset's allowlists:
| Preset | Name-kind allowlist | Behaviour |
|---|---|---|
entity-only | ['entity'] | Legal entity names only; the eval/test default, not the product default. |
business-name-aware (product default) | ['entity', 'business'] | Legal entities are also discoverable via a registered business name; results collapse to one row per ABN (collapseBusinessNames: true), with the matched business name surfaced as matchedBusinessName ("Trading as: X"). |
with-business-names | ['entity', 'business'] | Same allowlist as business-name-aware (collapseBusinessNames: false) — business names surface as their own uncollapsed rows, not folded into the parent legal entity. |
includeSoleTraders: true appends 'IND' (Individual/Sole Trader) to the resolved entity-type allowlist (default ['PUB', 'PRV']) and appends 'legal' to the name-kind allowlist, so a sole trader's legal-name row survives the pre-hydration filter regardless of which preset is active.
IND → OrganisationType mapping lives in packages/core/src/domain/constants/organisation.ts:
const ABR_CODE_TO_ORGANISATION_TYPE = {
PRV: OrganisationType.ProprietaryLimited,
PUB: OrganisationType.PublicLimited,
IND: OrganisationType.SoleProprietor,
} as const;
export const ABR_DERIVED_ORGANISATION_TYPES = Object.values(ABR_CODE_TO_ORGANISATION_TYPE);ABR_DERIVED_ORGANISATION_TYPES doubles as the trust boundary for selectOrganisationAction's Zod schema (z.enum(ABR_DERIVED_ORGANISATION_TYPES)) — a client cannot smuggle an arbitrary OrganisationType through the selection payload's type field.
AUS-only website enrichment: for the top-ranked ABR candidates (websiteTopK, default 20, overridable via SEARCH_WEBSITE_TOP_K), enrichAbrWebsites runs a Tavily web_search lookup for the official website, validates the answer through the domain website validator, and on acceptance attaches website + a WEBSITE identifier and sets websiteValidated: true. This step is bounded by REGISTRY_LEAD_MS — set SEARCH_WEBSITE_TOP_K conservatively in production, since a large value can push every AUS auto search past the registry's lead window.
OpenCorporates and the GBR Route
File: packages/core/src/infrastructure/repositories/open-corporates-repository.ts
Registry routing in search-organisations.ts is: country === 'AUS' → ABR; every other country, including GBR and GLO, → OpenCorporates. There is no Companies House branch in the live search path — see Known Limitations and Dead Code for the status of the old CompaniesHouseRepository.
OpenCorporatesRepository.searchByName(name, { countryCode?, signal? }) converts an alpha-2/alpha-3 countryCode (any case) to OpenCorporates' own alpha-2 jurisdiction format; an unmappable code yields an empty result rather than failing the whole search. A 403 (rate-limited) is not retried; a transient 5xx gets one retry after OC_RETRY_DELAY_MS. Like ABR, a genuine failure is reported via an error field on the result — the repository never throws to its caller.
Global (GLO) Scope
GLOBAL_SEARCH_SCOPE = 'GLO' (defined in packages/core/src/domain/constants/organisation.ts, barrel-exported from @repo/core) lets a user search worldwide instead of picking a single country. Selecting it:
- Drops the country filter on internal quick-search (all three lookups run unscoped).
- Passes
countryCode: undefinedtoOpenCorporatesRepository.searchByName, so OC returns matches from every jurisdiction. ABR is never used under Global (it's AUS-only). - Resolves each OpenCorporates candidate's real country from OC's own
jurisdictionCodevia a localocJurisdictionToAlpha3helper (splits sub-jurisdictions likeus_deon_, then normalises the base code) — a candidate whose jurisdiction can't be mapped (e.g.cs,an,eu) is dropped rather than mis-tagged. - Switches the agent's system instructions to an explicit worldwide-search mode ("no country restriction — return the best-matching organisations worldwide, each tagged with their actual headquarters country code"), then independently validates/normalises the agent's freeform country output via
resolveAgentCountryForGlobalSearch(built ontoAlpha3fromdomain/utils/country-codes.ts) — an unmappable agent-emitted country is dropped with a warning rather than trusted, mirroring the OC jurisdiction handling. The usual country-equality guard (raw.countryIsoAlpha3 !== country) is skipped only for Global, since there is no single expected country to compare against.
The GLO sentinel is guarded at multiple independent chokepoints so it can never anchor a persisted key or country:
composeOrganisationKey(domain/utils/compute-organisation-key.ts) throws if given'GLO'or the pre-existing'XXX'private-use sentinel.DeriveOrganisationKey.executecarries the same guard as defense-in-depth, with an explicit comment that a literalGLOreaching it means an unresolved sentinel leaked through — every Global candidate must resolve to a real country before key derivation.selectOrganisationAction's Zod schema rejectscountryIsoAlpha3 === 'GLO'when materialising a new organisation. This check is conditional: it's skipped when selecting an existing org, because a genuinely countryless org surfaced under Global legitimately carriescountryIsoAlpha3: ''(not'GLO') from quick-search's leak-guard.
Countryless organisations: SelectOrganisation.autoEnrichIfNeeded explicitly skips auto-enrichment when neither the candidate's nor the input's country resolves to a real code, deferring to the scorecard's manual "Enrich now" flow (which resolves a fallback country via ResolveAccountFallbackCountry) rather than guessing.
Dedup: Identical-or-Empty Identifier Sets
File: packages/core/src/domain/utils/registry-identifier-match.ts
Two same-key candidates merge only when registryIdentifiersMergeable(a, b) is true: either side has no identifiers, or both sides have identical identifier sets (same types, same values). A shared identifier type with a different value, or disjoint identifier types (one candidate ABN-only, another ACN-only), are treated as distinct legal entities — the later candidate is kept separate, with its key disambiguated via pickDisambiguator (preference order: ACN → ABN → CH (Companies House) → OC (OpenCorporates id) → first available identifier value). An identifier-less candidate still folds onto an existing registry row, preserving the "slow registry result patches in after the agent already rendered a plain-name row" behaviour.
This is materially tighter than a bare "same composite key ⇒ same entity" merge: it stops the search from silently collapsing two same-named, differently-registered legal entities into one row.
Website Validation
File: packages/core/src/domain/services/website-validator.ts
validateWebsite() is a pure domain service — deterministic, always-on, and runs entirely locally (no external calls of its own; it evaluates evidence already fetched by the caller). It has two tiers:
- Graded brand-domain affinity (
nameDomainAffinity,domain/utils/website-brand-match.ts) — accepts immediately above a threshold. - Content-grounding rescue — for names that don't clear the affinity bar (commonly abbreviations), checks whether Tavily's fetched page content actually confirms the candidate name against the resolved domain.
An earlier revision had a third, optional LLM "sniff" rescue tier gated behind an environment flag (WEBSITE_VALIDATOR_ENABLED). That tier and its env gate have been removed entirely — the LLM step was slow and not reliably more accurate than the deterministic tiers, so validation is now unconditional for every candidate that carries a website, whether from ABR enrichment or elsewhere. There is no per-request override to disable it.
Selection and Enrichment
When the user picks a candidate:
selectOrganisationActionreceives the candidate. If it already maps to an internal org (existingOrgId), no new organisation is created — but it is not a pure no-op read:attachIdentifiersunconditionally writes any identifiers carried on the candidate (routine for ABR/registry-derived candidates) viaAddOrganisationIdentifier, and a missingtypeis backfilled if the candidate carries one. Separately, if the existing org has never been enriched and isn't owned by the caller's account,SelectOrganisation.autoEnrichIfNeededalso triggers enrichment for it automatically (skipping the "Confirm Company Match" banner), provided a real country is resolvable (see Global (GLO) Scope).- Otherwise
MaterialiseOrganisationcreates-or-finds the node by composite key; the profile is markedPENDING(so the destination shows "Enriching…" rather than flashing "Enrich now"); and an Inngestorganisation.enrichevent is fired (rolled back on send failure). - Direct enrichment is the unconditional default, gated purely on website presence — there is no opt-in flag or toggle.
RequestOrganisationEnrichmentsetsprofileSource: 'direct'whenever a website is present (from the candidate or from validated ABR enrichment); the Inngest job (packages/core/src/application/jobs/organisation/enrich.ts) routes adirectevent toEnrichOrganisationFromDomain(CompanyEnrich), with an inline agentic fallback if the direct lookup misses. No website → the agentic pipeline runs directly; a manual "Enrich now" re-trigger also always runs agentic (it passes no website). - The orchestrator's AI-agent-driven path (
EnrichOrganisation, viaenrichOrganisationAgent) writes in two phases — scalar fields first so the UI updates fast, then the heavier writes:- Phase 1 (scalars): description, website, type
- Phase 2: country (
FROM_COUNTRYedge), NAICS classifications + Aquila1 sync, identifiers (type+valueonly — no source-URL field), legal-name alias, and parent/subsidiary stubs (related-entity stubs created by composite key). The logo is not persisted — it's computed at read time fromwebsite+BRANDFETCH_CLIENT_ID(see theOrganisation.logoresolver), so there is nologo-readymilestone.
- The
verify-organisation-agentgates promotion: on a "verified" verdict the:OWNED_BYownership edge is dropped, promoting the org from account-scoped to global/verified. - The client navigates to
buildSelectionDestination(intent, organisationId)(see Result Display). There is no?focusnavigation.
Database Layer
Organisation Schema
File: packages/core/src/infrastructure/neo4j/schemas/organisation.graphql
The Organisation node has relationships to the entities searched:
(Organisation) -[:HAS_IDENTIFIER]-> (Identifier {type, value})
(Organisation) -[:HAS_ALIAS]-> (Alias {value})
(Organisation) -[:FROM_COUNTRY]-> (Country {name, code})
(Organisation) -[:HAS_PRODUCT]-> (Product {name})Key searchable properties on Organisation: name, website, and key — the composite entity-equality key <ISO_ALPHA3>|<lowercased canonical name> (e.g. AUS|microsoft), built by composeOrganisationKey({ countryIsoAlpha3, canonicalName }). composeOrganisationKey rejects countryIsoAlpha3 values of 'GLO' or 'XXX' (see Global (GLO) Scope).
Note:
Identifiercarries only{ type, value }. There is nosourceUrlfield on identifiers (asourceUrlexists on theReportnode, not here).
GraphQL Query
File: packages/core/src/infrastructure/repositories/organisation/find-organisations.graphql
The FindOrganisations query fetches a comprehensive set of fields — identifiers, aliases, NAICS codes (with Aquila1), products, transactions, and country — plus profile/enrichment fields (aquila1, aquila2, ownedBy, redirectedTo, profileStatus, profileCheckedAt, …). The same query backs both search-result resolution and detail views — it fetches everything. Search-specific projections used by the quick-search and search routes live in dedicated, slimmer sibling files: find-organisation-for-search.graphql, find-organisations-for-search.graphql, and find-existing-organisation-ids-for-account.graphql (the batched accountLinked membership query).
Neo4j Indexes
Search-relevant indexes live across two migrations.
V008__indexes_and_constraints.cypher:
| Index | Type | Purpose |
|---|---|---|
organisation_name | Range | Equality/range on name |
organisation_website | Range | Equality/range on website |
organisation_name_text | Text | CONTAINS operations on name |
organisation_description_text | Text | CONTAINS operations on description |
alias_value_text | Text | CONTAINS operations on alias value |
identifier_type | Range | Equality on identifier type |
identifier_value | Range | Equality on identifier value |
(Unique constraints on Alias.value and Identifier(type, value) auto-create backing indexes.)
V086__diffbot_similarity_key_index.cypher:
| Index | Type | Purpose |
|---|---|---|
organisation_name_fulltext | FULLTEXT | Fuzzy-name lookup used by findByFuzzyName (db.index.fulltext.queryNodes + Jaro-Winkler scoring) |
organisation_key | Range | Lookup on the composite key (used by findByKey) |
The TEXT indexes support the CONTAINS predicate used by @neo4j/graphql's caseInsensitive filters; the FULLTEXT index powers the fuzzy-name path.
Caching
| What | Where | Notes |
|---|---|---|
Internal lookups (quick-search) | None | Every request hits Neo4j directly. |
| Registry + agent search results | None | /api/organisations/search sets Cache-Control: no-store; both channels run on every request. |
| ABR per-ABN hydration (detail lookup) | Redis | ABR_CACHE_TTL_SECONDS (1 hour), read-through — the dominant search-latency saver for ABR. ABR also has a per-record name-scoped cache with no production reader today. |
| OpenCorporates company-by-id and search-by-name | Redis | 30-day TTL on both. |
| Organisation key derivation | None | DeriveOrganisationKey/canonicaliseOrganisationName is a pure, deterministic regex-based sanitiser — no LLM call and no cache. (An earlier LLM-based canonicaliser used a Redis cache; it no longer applies now that derivation is deterministic.) |
| Client-side results | None | Ephemeral React state; no localStorage / sessionStorage. |
Validation and Rate Limiting
Validation is enforced at these layers:
| Layer | Rule |
|---|---|
POST /api/organisations/search | Zod: query min(1).max(200); countryIsoAlpha3 exactly 3 uppercase letters (accepts GLO); mode enum default 'auto'; abrPreset enum default 'business-name-aware'; websiteTopK optional int 0–30; includeSoleTraders optional boolean. Rate-limited to 30 requests/minute per account (checkRateLimit), returning 429 with a Retry-After header on breach. maxDuration = 60 (ABR hydration can take up to ~30s). |
POST /api/organisations/quick-search | Zod: same query/countryIsoAlpha3 shape. DB failures return a genuine 503, not a silent empty 200. |
Client (OrganisationSearch) | Won't trigger when the trimmed query is empty or no country is selected; shows inline invalid styling. |
| Repository | findByFuzzyName returns [] for empty/blank queries. |
The minimum query length is 1 character (non-empty after trim), not 2. There is no max-length truncation step — max(200) is enforced as a Zod rejection at the API boundary.
Feature Flags
File: apps/web/src/flags/index.ts
| Flag | Default | Effect |
|---|---|---|
hybridSearch | true (Edge Config-backed) | Client: shows the focus panel (All/New/Existing + Business names/Sole traders). Server: a genuine kill-switch — when off, /api/organisations/search force-downgrades any client-requested mode to 'agentic' regardless of what the client sends, so registry search cannot be reached even by a stale/bypassed client. |
commandKSearch | false | Gates the navbar ⌘K pill + SearchOverlay vs. the legacy dashboard inline form. When on, DashboardSearchSlot returns null and the overlay owns search. |
devMode | false | Gates the dev-only badges/keys/relevance score in CandidateResultItem and the Toolbar. |
searchReranking (Cohere rerank-v3.5, default true) also exists in this file but is not wired into any part of the organisation search flow described here — it applies to a different search surface.
Data Types
File: apps/web/src/app/(main)/@search/_lib/organisation-search-types.ts
Candidate is a real discriminated union on _type, not a single flattened shape:
export interface InternalQuickSearchCandidate {
_type: 'internal';
canonicalName: string;
description: string;
countryIsoAlpha3: string;
key: string;
existingOrgId: string;
source: 'internal';
logo: string | null;
website: string | null;
accountLinked: boolean;
}
// SearchOrganisationsCandidate (`_type: 'candidate'`) is re-exported verbatim from
// @repo/core so server → client drift is caught by tsc.
export type Candidate = SearchOrganisationsCandidate | InternalQuickSearchCandidate;
export type IdentifierPatch = SearchOrganisationsIdentifierPatch;
export type ChannelDegraded = SearchOrganisationsChannelDegraded;
export type StreamItem = SearchOrganisationsCandidate | IdentifierPatch | ChannelDegraded;accountLinked is a required boolean on both halves of the union. Quick-search rows are always _type: 'internal'; registry/agent rows are always _type: 'candidate' and carry registry-only fields (identifiers, websiteValidated, nameKind, matchedBusinessName, parentEntityName, type) that quick-search rows never have — narrow on _type rather than re-declaring a merged shape, since hand-flattening silently drops fields.
Helpers in the same module: displayDomain(url), toAlpha2(code). Merge/derive logic lives in the sibling organisation-search-merge.ts: mergeCandidates(quick, agent) (concatenate, de-duplicated by key, quick first), applyStreamItem(prev, item) (folds a NDJSON item — candidate or identifierPatch — into the running candidate list, including the existence-upgrade described in Registry + Agent Search), and deriveNoResults(...) (also accounts for registryDegraded, so a failed registry channel with zero candidates doesn't read as "no matches").
Known Limitations and Dead Code
CompaniesHouseRepository(packages/core/src/infrastructure/repositories/companies-house-repository.ts+companies-house-config.ts) is still present in the tree and still barrel-exported frompackages/core/src/index.ts, but has zero production callers — GBR search routes through OpenCorporates now (see OpenCorporates and the GBR Route). Only its own test file imports it. This is orphaned code pending deletion, not a currently-live alternate path.searchABRagent tool (packages/core/src/application/ai/agents/tools/search-abr.ts) wrapsABRRepository.searchByNameas a Vercel AI SDK tool but is not registered onsearchOrganisationsAgent(whose only tool isweb_search) and has no other importer. The agent genuinely has no DB/registry lookup tool today — this file just shouldn't be mistaken for a wired capability.use-organisation-search.ts(the standalone hook in_lib/) implements the same search state machine asorganisation-search.tsx, but the main dashboard/engage-slot component does not use it — it reimplements the logic inline with the scope/toggle additions layered on. The hook is not dead, though: it has a genuine live consumer,ToolbarSpotlightSearch(apps/web/src/app/(main)/engage/components/toolbar-spotlight-search.tsx), rendered on the/engagesticky header — a narrower search surface with no focus panel (no scope selector, no Business names/Sole traders toggles). Treat the hook as the engage-toolbar's search implementation, not as the source of truth for the mainOrganisationSearchcomponent's behaviour.decode-xml-entities.tshardening (guards againstRangeErroron out-of-range numeric XML entities) is not used anywhere in the ABR/OpenCorporates search path — ABR's XML parsing goes throughcheerio, which handles entity decoding itself. The hardened decoder's actual consumers are the BHRRC issues-research pipeline.- AUS website enrichment (
enrichAbrWebsites) shares its time budget with the registry lead window (REGISTRY_LEAD_MS) — aSEARCH_WEBSITE_TOP_Kset too high for the deployment's Tavily latency can push AUSautosearches past the 5-second registry lead, causing the agent to stream first and registry results to arrive as lateidentifierPatchupgrades more often than intended. - Global scope is worldwide by definition, so it cannot apply the AUS-only Business names / Sole traders toggles or ABR website enrichment — both are unavailable whenever Global is selected, mirroring their unavailability for any non-AUS country.