Fair Supply LogoFair Supply - Docs

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

┌────────────────────────────────────────────────────────────────────────────────┐
│  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 AbortControllerrequest.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 the search prop into (main)/layout.tsx, which renders it through SearchModeLayout.
  • @header (app/@header/, root level) — supplies the top nav bar. Passed as the header prop into the root app/layout.tsx. It swaps the normal light MainHeader for the dark SearchModeHeader while 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

RouteResolves toRenders
/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.tsxnull
unmatched / hard-refresh fallback@search/default.tsxnull

There is no @search/assess/page.tsx. On /assess the search slot renders null via the catch-all. The dark "Company Search" header seen on /assess comes from the @header slot, 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 renders null, so the previous OrganisationSearch unmounts on navigation.
  • default.tsx is 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)

RouteResolves toHeader
/dashboard@header/dashboard/page.tsxSearchAwareHeader (no label)
/assess@header/assess/page.tsxSearchModeHeader directly when ?module=spotlight|analyst; otherwise SearchAwareHeader (label "Company Search")
every other route@header/default.tsxMainHeader

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 a searchIntent param in the URL (e.g. the Spotlight/Analyst cards on /assess navigate 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 calls closeSearch() 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):

  • hasSearchContent resets on unmount via its effect cleanup.
  • isSearchActive resets 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 isSearchActive from 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: a useMemo merges the quick and registry/agent candidates (mergeCandidates) and then filters on accountLinkednew keeps unlinked candidates, existing keeps linked ones, all passes the merge through untouched. The one exception is existing, 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, default false) and Sole traders toggle (includeSoleTraders, default false) — rendered only when country === 'AUS', and disabled while scope === '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 sets includeSoleTraders: true, widening the ABR entity-type allowlist to include IND (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 pointTypePurpose
POST /api/organisations/quick-searchAPI routeFast 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/searchAPI routeRegistry + 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.
selectOrganisationActionserver actionUser 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.
triggerEnrichmentActionserver actionRe-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.accountLinked is true, distinct from the dev-mode existing/new tag described below.
  • "Trading as: X" / "Trading entity of: X" subtitle, derived from matchedBusinessName / parentEntityName (mutually exclusive) — surfaces when an ABR business-name-aware search 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):

searchIntentDestination
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

ParameterPurpose
qSearch query. Written to the URL when a search runs; seeds initialQuery on load.
countryISO 3166-1 alpha-3 country filter, or GLO for Global. Written alongside q; seeds initialCountry.
searchIntentDestination intent set by a launcher (spotlight / analyst / engage); read on selection to pick the destination.
returnToBack-navigation target carried with searchIntent; both are stripped when search is closed without selecting.
fromSet 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:

LookupWhenBehaviour
OrganisationRepository.findByFuzzyName({ query, accountId, countryIsoAlpha3?, limit })alwaysFuzzy 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/domainNormalised 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).

/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':

ModeBehaviour
agenticAI web-search agent only. No registry call; abrPreset/includeSoleTraders are ignored.
autoProduction default. Registry and agent run concurrently.
directRegistry 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:

PresetName-kind allowlistBehaviour
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:

  1. Drops the country filter on internal quick-search (all three lookups run unscoped).
  2. Passes countryCode: undefined to OpenCorporatesRepository.searchByName, so OC returns matches from every jurisdiction. ABR is never used under Global (it's AUS-only).
  3. Resolves each OpenCorporates candidate's real country from OC's own jurisdictionCode via a local ocJurisdictionToAlpha3 helper (splits sub-jurisdictions like us_de on _, then normalises the base code) — a candidate whose jurisdiction can't be mapped (e.g. cs, an, eu) is dropped rather than mis-tagged.
  4. 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 on toAlpha3 from domain/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.execute carries the same guard as defense-in-depth, with an explicit comment that a literal GLO reaching it means an unresolved sentinel leaked through — every Global candidate must resolve to a real country before key derivation.
  • selectOrganisationAction's Zod schema rejects countryIsoAlpha3 === '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 carries countryIsoAlpha3: '' (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: ACNABNCH (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:

  1. Graded brand-domain affinity (nameDomainAffinity, domain/utils/website-brand-match.ts) — accepts immediately above a threshold.
  2. 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:

  1. selectOrganisationAction receives 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: attachIdentifiers unconditionally writes any identifiers carried on the candidate (routine for ABR/registry-derived candidates) via AddOrganisationIdentifier, and a missing type is 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.autoEnrichIfNeeded also triggers enrichment for it automatically (skipping the "Confirm Company Match" banner), provided a real country is resolvable (see Global (GLO) Scope).
  2. Otherwise MaterialiseOrganisation creates-or-finds the node by composite key; the profile is marked PENDING (so the destination shows "Enriching…" rather than flashing "Enrich now"); and an Inngest organisation.enrich event is fired (rolled back on send failure).
  3. Direct enrichment is the unconditional default, gated purely on website presence — there is no opt-in flag or toggle. RequestOrganisationEnrichment sets profileSource: '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 a direct event to EnrichOrganisationFromDomain (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).
  4. The orchestrator's AI-agent-driven path (EnrichOrganisation, via enrichOrganisationAgent) 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_COUNTRY edge), NAICS classifications + Aquila1 sync, identifiers (type + value only — 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 from website + BRANDFETCH_CLIENT_ID (see the Organisation.logo resolver), so there is no logo-ready milestone.
  5. The verify-organisation-agent gates promotion: on a "verified" verdict the :OWNED_BY ownership edge is dropped, promoting the org from account-scoped to global/verified.
  6. The client navigates to buildSelectionDestination(intent, organisationId) (see Result Display). There is no ?focus navigation.

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: Identifier carries only { type, value }. There is no sourceUrl field on identifiers (a sourceUrl exists on the Report node, 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:

IndexTypePurpose
organisation_nameRangeEquality/range on name
organisation_websiteRangeEquality/range on website
organisation_name_textTextCONTAINS operations on name
organisation_description_textTextCONTAINS operations on description
alias_value_textTextCONTAINS operations on alias value
identifier_typeRangeEquality on identifier type
identifier_valueRangeEquality on identifier value

(Unique constraints on Alias.value and Identifier(type, value) auto-create backing indexes.)

V086__diffbot_similarity_key_index.cypher:

IndexTypePurpose
organisation_name_fulltextFULLTEXTFuzzy-name lookup used by findByFuzzyName (db.index.fulltext.queryNodes + Jaro-Winkler scoring)
organisation_keyRangeLookup 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

WhatWhereNotes
Internal lookups (quick-search)NoneEvery request hits Neo4j directly.
Registry + agent search resultsNone/api/organisations/search sets Cache-Control: no-store; both channels run on every request.
ABR per-ABN hydration (detail lookup)RedisABR_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-nameRedis30-day TTL on both.
Organisation key derivationNoneDeriveOrganisationKey/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 resultsNoneEphemeral React state; no localStorage / sessionStorage.

Validation and Rate Limiting

Validation is enforced at these layers:

LayerRule
POST /api/organisations/searchZod: 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-searchZod: 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.
RepositoryfindByFuzzyName 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

FlagDefaultEffect
hybridSearchtrue (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.
commandKSearchfalseGates the navbar ⌘K pill + SearchOverlay vs. the legacy dashboard inline form. When on, DashboardSearchSlot returns null and the overlay owns search.
devModefalseGates 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 from packages/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.
  • searchABR agent tool (packages/core/src/application/ai/agents/tools/search-abr.ts) wraps ABRRepository.searchByName as a Vercel AI SDK tool but is not registered on searchOrganisationsAgent (whose only tool is web_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 as organisation-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 /engage sticky 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 main OrganisationSearch component's behaviour.
  • decode-xml-entities.ts hardening (guards against RangeError on out-of-range numeric XML entities) is not used anywhere in the ABR/OpenCorporates search path — ABR's XML parsing goes through cheerio, 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) — a SEARCH_WEBSITE_TOP_K set too high for the deployment's Tavily latency can push AUS auto searches past the 5-second registry lead, causing the agent to stream first and registry results to arrive as late identifierPatch upgrades 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.

On this page