Fair Supply LogoFair Supply - Docs

Manual NAICS Classification

This document explains how manual NAICS classification is implemented: why it exists, how the "+ Add" control behaves across its three surfaces, the write path from a selected NAICS code through to Aquila1 sector weights and transaction backfill, and the concurrency/self-heal guarantees the use case makes.

The feature is flag-gated (manualNaicsClassification, default off) and shipped as PR #1519.


Table of Contents


Why This Exists

Enrichment doesn't always succeed in classifying an organisation with a NAICS Business Activity code — an org enriched via the automated pipeline can come back with no classification at all. Without a classification, an org's Aquila1 sector weighting never populates, so its footprint, risk, and intensity figures stay null indefinitely on every surface that depends on them (identify, analyst, spotlight). The only prior recourse was waiting for a re-enrichment pass that might never fix the gap — a real blocker for due-diligence and search workflows where a user is on the platform waiting for a result, and a recurring pain point for accounts with many smaller suppliers that need verified risk assessments.

Manual classification lets a user directly attach a NAICS code to an enriched-but-unclassified organisation, closing that gap without waiting for re-enrichment.


High-Level Architecture

┌───────────────────────────────────────────────────────────────────────────┐
│  Three surfaces, one shared control: <AddClassificationControl />          │
│    • identify sidebar   (sidebar-categorisation.tsx)                       │
│    • analyst page       (classification.tsx)                              │
│    • spotlight scorecard (naics-classifications.tsx)                       │
│                                                                             │
│  Renders only when: manualNaicsClassificationEnabled && enrichmentComplete │
│  && (org has zero Aquila1 classifications OR an add already happened       │
│      this visit — the "multi-add latch")                                   │
│                                                                             │
│  "+ Add" trigger (AddNAICSSession)                                         │
│       └─► click ─► lazy-loads AddNAICSButton (next/dynamic, ssr:false)     │
│              └─► SearchCombobox (variant="default-dark-popup")             │
│                     └─► debounced GET /api/naics/search?term=...           │
│                            (route handler — NOT a server action, to avoid  │
│                             per-keystroke Router Cache invalidation)       │
│                     └─► NAICSRepository.findByNameAndLevel(term, leaf)     │
│                                                                             │
│  On select ─► addOrganisationNAICSAction (server action)                   │
│       └─► requireAccount() + manualNaicsClassification() kill switch       │
│       └─► AddOrganisationNAICS.execute({ organisationId, naicsCode })      │
│              1. per-org Redis lock (SET NX EX, fail-fast, no polling)      │
│              2. connectNAICS  — MERGE (Organisation)-[:HAS_NAICS]->(NAICS) │
│              3. SyncOrganisationAquila1FromNAICS — full NAICS-set          │
│                 delete-then-rewrite of the org's Aquila1 basket            │
│              4. read both Aquila1 baskets (SPENT / INVESTED)               │
│              5. backfillTransactionAquila1 — stamp org's existing,         │
│                 not-yet-classified transactions with the new sectors       │
│       └─► revalidatePath(/organisations/[id], 'layout')                    │
│           (NOT /identify — see Cache Invalidation)                        │
└───────────────────────────────────────────────────────────────────────────┘

Scope and Constraints

  • NAICS only, not Aquila1 directly. Users pick a NAICS Business Activity code; Aquila1 sector weights are always derived from the NAICS set, never chosen directly.
  • Leaf-level only. The typeahead only offers level 5 (6-digit) NAICS codes — the most granular tier. NAICS_LEAF_LEVEL lives in NAICSRepository as a domain fact, not a route-handler constant.
  • Additive and irreversible. A user can add multiple NAICS codes to the same org in one visit. There is currently no way to remove a manually-added classification — deletion is a possible future improvement.
  • Gated on emptiness, not enrichment outcome. The control only appears when an org's profileStatus has settled (COMPLETED or FAILED — see isEnrichmentComplete) and it has zero existing Aquila1 classifications. It does not appear for orgs that already have at least one classification, whether from enrichment or a prior manual add.
  • Transactions roll down automatically. If the org has transactions, adding a NAICS code backfills the newly-derived Aquila1 sectors onto any of the org's existing, not-yet-classified transactions so footprint/risk calculations can run. Already-classified transactions are never touched.
  • Flag-gated end to end. The whole feature sits behind manualNaicsClassification (default off), enforced independently in the UI, the server action, and the use case itself.

Frontend

Component Hierarchy

AddClassificationControl                    (owns the multi-add latch; one per surface)
└── AddNAICSSession                          (trigger ⇄ combobox toggle; lazy-loads the combobox)
    ├── "+ Add" Button (collapsed state)
    └── AddNAICSButton (expanded state, next/dynamic ssr:false)
        └── SearchCombobox (@repo/luz)       (debounced typeahead + selection)

AddClassificationControl is the single component imported by all three surfaces — sidebar-categorisation.tsx, classification.tsx (analyst), and naics-classifications.tsx (spotlight scorecard).

AddClassificationControl — the multi-add latch

File: apps/web/src/components/shared/naics/add-classification-control.tsx

Owns a sessionStarted flag that flips to true on the first successful add and is never cleared for the lifetime of the mount:

if (!initiallyUnclassified && !sessionStarted) return null;
  • initiallyUnclassified is computed by the parent at page load from the org's Aquila1 classification count (not NAICS-tag count — NAICS is only the means, Aquila1 is what "classified" means everywhere else in the product).
  • Without the latch, a successful add would trigger a revalidatePath that flips initiallyUnclassified to false server-side, and the control would immediately vanish — blocking a user who wants to add several NAICS categories to the same org in one visit.
  • This depends on the parent keeping AddClassificationControl mounted at a stable JSX position, gated only on enrichmentComplete (which an add never changes) — never on live classification count. All three integration points follow this contract; see naics-classifications.tsx for the trickiest case.
  • handleAdded order matters: it sets the latch first, then invalidates the relevant TanStack Query caches (queryKeys.organisations.risk, queryKeys.organisations.flagCounts) so the scorecard reflects the new classification, then calls the surface-specific onAdded callback.
  • A justAddedRef (a ref, not state) tracks whether an add has happened in this session, driving AddNAICSSession's one-shot post-add focus restoration — see below.
  • Rendered with key={organisationId} at the identify sidebar call site only (sidebar-categorisation.tsx), so the latch resets there when the sidebar switches to a different organisation within the same mounted page. The analyst and spotlight scorecard call sites carry no key prop — both surfaces already remount the whole page/section on an organisation change (a full navigation to /organisations/[id]/analyst or /organisations/[id]/spotlight), so AddClassificationControl itself gets torn down and recreated without needing an explicit key.

AddNAICSSession — trigger vs. combobox, lazy-loaded

File: apps/web/src/components/shared/naics/add-naics-session.tsx

A "session" toggles between the collapsed "+ Add" Button and the expanded AddNAICSButton combobox via local sessionActive state.

  • Lazy-loading: AddNAICSButton is loaded via next/dynamic({ ssr: false }) with a fixed-size placeholder (h-input w-72) to prevent layout shift. This keeps the Base UI combobox dependency out of the initial bundle for every org detail page — it only ships once a user opens the add flow. The chunk is prefetched on onPointerEnter / onFocus of the trigger button (a direct call to the dynamic import's loader, not through the wrapped component), so it's typically warm before the click lands.
  • Post-add focus restoration: the justAdded prop is true only when a render is caused by AddClassificationControl's latch just firing (i.e., right after a successful add) — not on a plain first mount. A useEffect keyed on justAdded refocuses the trigger button, since collapsing the combobox back to the trigger would otherwise silently drop focus to <body>. An ordinary fresh mount (first load of an unclassified org) never steals focus — this was a real accessibility bug caught in review before merge (the control originally stole focus on every mount).
  • Two dismiss mechanisms, chosen for portal correctness:
    1. A plain mousedown listener with DOM-containment checks against the wrapper — active only while the popup itself is closed (nothing has portaled yet).
    2. Once the popup is open, dismissal is delegated to SearchCombobox's own onOpenChange reason, because the popup content renders into a Base UI portal outside the wrapper's DOM subtree — plain containment checks can't see clicks inside it.
  • handleComboboxOpenChange collapses the session only on 'outside-press' / 'focus-out'. It deliberately excludes:
    • 'item-press' — a selection was made but the add's outcome (success/error) isn't known yet; collapsing here would hide an inline error.
    • 'escape-key' — Escape closes only the popup, keeping the input focused for another search attempt (parity with common combobox UX).
  • handleAdded collapses the session on every successful add. Multi-add works not because the combobox stays open, but because the parent's latch (sessionStarted) keeps the trigger rendering across adds — collapse-then-reopen-via-"+"" is the intended flow, not "leave the combobox open."

AddNAICSButton — the search + submit UI

File: apps/web/src/components/shared/naics/add-naics-button.tsx

The expanded state — wraps SearchCombobox with the search/select/submit logic.

  • Uses useServerAction(addOrganisationNAICSAction) (zsa-react).
  • handleSearch fetches GET /api/naics/search?term=... with cache: 'no-store', forwarding the combobox's own AbortSignal so a superseded keystroke's in-flight request is actually cancelled, not just ignored.
  • Two independent post-submit UI states — error and syncNotice — render into a single, always-mounted role="status" aria-live="polite" region with only the child text swapped. A conditionally-mounted live region often fails to announce to screen readers; keeping it mounted avoids that.
  • Error handling distinguishes err.name === 'ConflictError' (another add already in flight for this org — see the Redis lock) from generic failures, and shows a friendlier message for the former. This relies on the fact that zsa's serialization loses class identity across the server/client boundary but preserves .name — the same convention documented in this repo's security rules for dispatching on error type from client code.
  • On a successful add where aquila1Synced === false, shows a soft syncNotice ("Added — but we couldn't classify this category…") rather than an error, since the NAICS tag did attach — only the derived Aquila1 sectors came back empty.
  • Renders SearchCombobox with variant="default-dark-popup", minChars={2}, disabled while the action is pending.

The Three Integration Points

All three surfaces gate on the same two conditions — the flag and enrichmentComplete — before rendering AddClassificationControl, but they don't share one literal expression:

// Identify sidebar and spotlight scorecard both bind a canAdd constant, using strict
// equality (props arrive as boolean | undefined):
const canAdd = manualNaicsClassificationEnabled === true && enrichmentComplete === true && !!organisationId;

// Analyst page (a server component) has no canAdd binding — the gate is inlined
// directly in JSX, and it omits the organisationId check:
{manualNaicsClassificationEnabled && enrichmentComplete && (
  <AddClassificationControl organisationId={organisationId} initiallyUnclassified={isEmpty} />
)}

initiallyUnclassified is separately computed per surface from the org's Aquila1 classification count being zero.

SurfaceFileData sourceRefresh after add
Identify sidebarsidebar-categorisation.tsxClient-fetched into a Zustand store (useIdentifySidebarStore)refreshOrganisationCategorisation — a targeted client-side patch (see Cache Invalidation)
Analyst page.../analyst/_components/sections/classification.tsxServer component, GetClassifications use caserevalidatePath RSC re-render only — no client callback needed
Spotlight scorecard.../spotlight-scorecard/components/naics-classifications.tsxClient component, organisation.aquila1 from parentrevalidatePath (server-rendered parent) + TanStack Query cache invalidation via the latch's handleAdded

Identify sidebar wiring. manualNaicsClassificationEnabled is read server-side in /identify/layout.tsx and threaded down through identify-sidebar-panel.tsxcompany-detail-sidebar.tsxsidebar-categorisation.tsx as a plain boolean prop. Because the sidebar's org data lives in a Zustand store rather than the Router Cache, a revalidatePath can't refresh it — handleAdded explicitly calls refreshOrganisationCategorisation(organisationId, transactionType) (from useSidebarFetchers), which re-fetches the single organisation from GET /api/identify/sidebar/[organisationId]/organisation and merges only naics/aquila1/aquila2 into the existing store bucket — preserving other page-query-only fields (like potentialMatchesConnection and Risk-tab counts) that a full replace would clobber. It's deduped via its own in-flight map, kept separate from the sidebar's primary fetch dedup so the two flows' promises never cross-wire. On failure it silently leaves the bucket untouched — the add already committed server-side; the tag just won't show until the next full reload.

Analyst page is the simplest: a server component that conditionally renders AddClassificationControl and relies entirely on revalidatePath's RSC refresh — no onAdded callback needed.

Spotlight scorecard has the trickiest layout constraint: the add control and the NAICS tags render inside a single Flex row, because the tags-vs-no-tags structural shape flips after revalidatePath, and if the add control lived in a conditionally-rendered branch of that shape, React would unmount/remount it on the first add — destroying the in-visit latch and making the "+ Add" trigger disappear. The add control itself sits unconditionally after the tags/show-more content; it's the "Show more" button (not the add control) that claims ml-auto (right-alignment) — and only when canAdd is false — so the row never strands a gap on the right when there's no add control to its right.

The SearchCombobox Primitive (@repo/luz)

Files: packages/luz/src/components/inputs-and-forms/SearchCombobox/{SearchCombobox.tsx, use-async-search.ts}

Built on @base-ui/react/combobox, SearchCombobox is a new general-purpose async search-and-select primitive — not NAICS-specific — with its own Storybook stories and unit tests. It ships three visual variants:

VariantUsed byNotes
defaultLight input, light popup
toolbarMatches the dark search-bar/toolbar styling
default-dark-popupManual NAICS add flowLight input, dark popup — matches the dark Dropdown styling used elsewhere on these pages

Key props: onSearch(query, signal?), onValueChange(value, option), minChars (default 2), debounceMs (default 300), clearable (default true), promptMessage, emptyMessage, errorMessage, disabled, variant, aria-label (required).

Debounce and cancellation (use-async-search.ts). States: idle | loading | success | empty | error.

  • The debounced worker closes over onSearch/minChars via refs, not directly — useDebounceCallback (from usehooks-ts) memoizes its debounce instance on [func, delay], so if the worker's identity changed every render (as it would with an inline onSearch prop), each keystroke would spawn a new debounce instance whose predecessor's pending timer was never cancelled — turning N keystrokes into N calls instead of coalescing to one.
  • Cancellation combines a monotonic tokenRef counter with an AbortController: each call bumps the token and aborts the prior controller before issuing a new request; on resolution, a stale token is discarded even if a caller ignores the abort signal. AbortError is swallowed.
  • Prior results stay visible while a new search is loading, avoiding an empty-list flash between keystrokes.
  • Below minChars, search() synchronously cancels any pending/in-flight request and resets to idle — it doesn't wait for the debounce timer to fire and then no-op.

Controlled-value handling (SearchCombobox.tsx). The popup only opens once there's content to show (status !== 'idle' or a promptMessage is set), preventing an empty dropdown from flashing open before the user has typed enough. A "no-clobber" guard defers syncing a controlled value into the input's displayed text until the selected option is actually resolved (found in the current results or supplied as selectedOption) — otherwise in-progress typing could be stomped by a stale controlled value. If the currently selected item isn't present in the latest results window, it's prepended to the rendered item list so Base UI can still resolve its label.

No next/dynamic call lives inside this component — the lazy-loading is entirely the caller's responsibility (see AddNAICSSession above).


Backend

Typeahead: GET /api/naics/search

File: apps/web/src/app/api/naics/search/route.ts

A route handler, not a server action — deliberately, mirroring the identify sidebar's own read endpoints (api/identify/sidebar/[organisationId]/*). A server-action-based typeahead read would invalidate the client Router Cache and re-render whatever route is currently open on every keystroke — on /identify that means re-running the whole-account FindOrganisationsWithFootprint aggregation each time. A route handler sidesteps the Router Cache entirely, honours real request cancellation via AbortSignal, and runs outside zsa's serialized action queue.

  • Auth: requireAccountOr401().
  • Query param term (trimmed): below MIN_TERM (2 chars) returns [] without querying (the combobox also enforces minChars, this is defense-in-depth); above MAX_TERM (200 chars) returns 400 rather than running an unbounded scan.
  • Calls NAICSRepository.findByNameAndLevel(term, NAICS_LEAF_LEVEL) — hardcoded to the leaf (6-digit) level, the only tier manual classification offers.
  • Response is a minimal NAICSSearchResult[] ({ name, code }[]) — the repository result also carries id, level, and timestamps, but only name/code are sent to the client.
  • Errors are logged and mapped to a generic 500 { error: 'Search temporarily unavailable' } — no internal detail leaks to the client.

The Write Path: addOrganisationNAICSAction

File: apps/web/src/components/shared/naics/actions.ts

export const addOrganisationNAICSAction = createServerAction()
  .input(z.object({ organisationId: z.string().min(1), naicsCode: z.string().regex(NAICS_CODE_FORMAT) }))
  .handler(async ({ input }) => {
    const { accountId } = await requireAccount();
    if (!(await manualNaicsClassification())) {
      throw new ForbiddenError('Manual NAICS classification is not available on your account.');
    }
    const { aquila1Synced, transactionsClassified } = await new AddOrganisationNAICS().execute({ ... });
    // ...
  });
  • Co-located with the NAICS components rather than under a specific route's actions.ts, since the feature isn't owned by any single page.
  • The kill-switch check here is deliberately redundant with the use case's own self-defending check (see below) — a UX-only short-circuit that skips the @repo/core call entirely when the flag is off, saving a wasted round-trip.
  • Uses ForbiddenError, not UnauthorizedError — this is a per-resource/feature-availability gate, not a session-level failure (mirrors sendEngagement / transactionUpload).
  • aquila1Synced: false on a successful response is logged via logWarning (not thrown) — a real write succeeded (the org is now NAICS-tagged), but the derived Aquila1 sectors came back empty, so footprint scores stay null for this org's transactions until a concordance gap is fixed upstream.

The Use Case: AddOrganisationNAICS

File: packages/core/src/application/use-cases/organisation/add-organisation-naics.ts

Runs five sequential steps against Neo4j with no shared transaction boundary across them:

  1. Acquire a per-organisation Redis lock (before any reads — see Concurrency).
  2. Read the org (findByIdForAccount, IDOR-safe) and the target NAICS code, then connect it (connectNAICS, idempotent MERGE) unless the org already carries that exact code.
  3. Sync Aquila1 from the org's full NAICS code set — existing codes ∪ the newly-added one, computed in memory from the already-loaded org, not a post-connect re-read. Using the full set (not just the new code) matters because the sync is a delete-then-rewrite of the whole Aquila1 partition; passing only the new code would delete the sectors derived from the org's pre-existing codes.
  4. Read both Aquila1 baskets (SPENT and INVESTED) after the sync.
  5. Backfill existing transactions with the freshly-synced baskets.

Self-defending flag gate. The use case re-checks manualNaicsClassification itself via getFeatureFlag (the @repo/core-safe Edge Config reader), independent of the web action's check — flags/next's flag() object is Next.js-specific and can't be imported into @repo/core without inverting the dependency direction. Both reads ultimately hit the same Edge Config value, so the two checks cannot diverge.

Format guard. parseNaicsCode re-validates the six-digit format (NAICS_CODE_FORMAT = /^\d{6}$/) and returns a branded NaicsCode type, so every downstream use of the code is provably validated — defense-in-depth alongside the action layer's Zod schema, since the use case is also a direct @repo/core export callable without going through the action.

"Already attached" is an optimization, not the correctness guarantee. Skipping connectNAICS when the org already carries the code just avoids a redundant write — MERGE at the database level is already idempotent under concurrent adds of the same code. Critically, the Aquila1 sync and backfill steps still run even on a redundant add, because re-running them is exactly what heals a prior partial failure (see below).

Concurrency: the Per-Organisation Redis Lock

A short-lived (30s TTL), non-blocking, SET NX EX lock — mirroring the idiom used by FootprintRepository.acquireLock, but without polling. Acquired first, before any org/NAICS reads, so a losing concurrent call fails fast rather than doing wasted work before discovering it lost the race.

Why this is needed: two simultaneous execute() calls for the same organisation (a double-click before the combobox's disabled state propagates, or the sidebar and spotlight scorecard both submitting at once) could each read the same pre-add NAICS snapshot, both pass the "already attached" check, and both call SyncOrganisationAquila1FromNAICS — a delete-then-rewrite of the entire Aquila1 partition — each computed from a code set missing whatever the other call just added. Whichever rewrite lands last silently discards the other call's classification (a classic lost update). A shared transaction boundary wouldn't close this gap either, since both calls still start from the same pre-write read.

A losing call throws ConflictError immediately (surfaced to the user as "Another NAICS classification is already in progress… try again shortly") rather than queueing — a loud, rejected duplicate is preferable to a classification silently vanishing. Lock release happens in a finally block; a release failure (e.g. Redis dropped between acquire and release) is logged, not rethrown, so it never masks whatever error the guarded block actually threw. The org stays lock-wedged until the TTL expires — a bounded, self-healing degradation.

Self-Heal vs. Transaction Boundary

A cross-repository transaction primitive does exist in this codebase — withTransaction / withOptionalTransaction (packages/core/src/infrastructure/graphql/with-transaction.ts), used by e.g. create-engagement.ts to wrap several repository calls in one Neo4j transaction with rollback on failure. AddOrganisationNAICS deliberately does not reach for it, because it wouldn't close the gap that actually matters here: the lost-update race (see Concurrency above) is caused by two calls starting from the same pre-write read, not by a lack of atomicity within a single call — wrapping one call's five steps in a transaction doesn't stop a second, concurrent call from reading the same stale snapshot. That's why the fix is the Redis lock, not a transaction boundary. What a transaction boundary would help with — a single call failing partway through and leaving inconsistent state — is instead handled by making the use case self-healing for sequential retries:

  • connectNAICS is idempotent (MERGE).
  • The Aquila1 sync and transaction backfill are idempotent full-set rewrites driven by the org's current NAICS codes, not a diff against a prior run.
  • If execute() throws partway through (e.g. after the connect commits but before the sync runs), the org is left NAICS-tagged but Aquila1-unsynced — and simply re-running the same use case with the same input completes the healing.

This guarantee is sequential-retry-only, not concurrency-safe — that's what the Redis lock above is for. There's also a distinct partial-failure shape inside the sync step: Aquila1 edges can land at weight = 0 and weight recalculation can subsequently throw; that throw propagates out of execute() uncaught (no blanket try/catch swallows it).

Separately, the "sync produced no sectors for either basket" outcome (aquila1Synced: false) does not throw — it's a soft/degraded outcome (a concordance gap for these particular NAICS codes, or the sync returning early on no candidates), logged and surfaced to the caller via the output rather than only being inferable by re-querying afterward. This case is a permanent gap, not something a retry fixes — re-adding the same code is a no-op on connectNAICS (already attached) and the sync will deterministically produce the same empty result from the same concordance data. The self-heal guarantee above applies to a mid-sequence failure (connect succeeded, sync didn't run); it does not apply to a concordance gap where the sync did run but had nothing to map to.

Aquila1 Sync: SyncOrganisationAquila1FromNAICS

File: packages/core/src/application/use-cases/organisation/manage-organisation-products.ts

Takes { organisationId, naicsCodes } and no-ops if empty. Each NAICS code carries two independent concordance baskets keyed by TransactionTypeSPENT (procurement mapping) and INVESTED (investment mapping) — fetched in parallel via Aquila1Repository.findByNaicsCodes.

For each basket, OrganisationRepository.setAquila(organisationId, candidateIds, [], forType) writes the full concordance-derived set — a delete-then-rewrite, not an incremental add. This replaced an earlier LLM-based refiner (removed in #1390) that judged the union of both baskets and applied one flat "kept" set to each independently; because setAquila's rewrite only re-inserts when the kept list is non-empty (the delete half runs unconditionally), that union judgment could silently wipe out an entire basket (commonly INVESTED, since the old prompt down-weighted capital-flavoured sectors). Each basket is now written straight from its own deterministic concordance, in full, with no LLM pruning step.

Gotcha: setAquila unconditionally deletes both HAS_AQUILA1 and HAS_AQUILA2 edges for the given forType basket before rewriting — and this call site always passes an empty array for the Aquila2 side. In practice this means a manual NAICS add will silently clear any existing Aquila2 custom-category edges on that basket, since nothing here ever re-populates them. This is a pre-existing property of setAquila, not something introduced by this feature, but it's a real side effect of calling it from this flow worth knowing about if Aquila2 data is ever populated through another path for a manually-classified org.

After both setAquila calls, weights are recalculated and normalized independently per non-empty basket via CalculateAquila1Weights + OrganisationRepository.updateAquila1Weights.

Transaction Backfill

Method: OrganisationRepository.backfillTransactionAquila1

Mirrors the classify-only half of the bulk-upload sweep (sweepConnectTransactionsAndAquila1) — without its upload-scoped :WITH connect pass, since these transactions are already linked to the organisation. For a given transactionType, it:

  1. Matches the account's transactions linked to the org that don't already have a HAS_AQUILA1 {for: transactionType} edge (WHERE NOT EXISTS {...}).
  2. For each, unwinds the org's just-synced Aquila1 basket and MERGEs a HAS_AQUILA1 {for: transactionType} edge with ON CREATE SET r.weight = aq.weight.
  3. Returns classifiedCount — the number of transactions newly classified.

Idempotent by construction: the NOT EXISTS pre-filter means already-classified transactions for that basket are skipped entirely, never overwritten.


Database Layer

Graph Shape

(Organisation) -[:HAS_NAICS]-> (NAICS {code, level})
(NAICS)        -[:HAS_AQUILA1 {for: TransactionType}]-> (Aquila1)        // concordance
(Organisation) -[:HAS_AQUILA1 {weight, for: TransactionType}]-> (Aquila1) // org's derived basket
(Transaction)  -[:HAS_AQUILA1 {weight, for: TransactionType}]-> (Aquila1) // per-transaction classification
  • NAICS.levellevel 5 is the leaf (6-digit codes); this is the only level manual classification offers. NAICS_LEAF_LEVEL lives in NAICSRepository, moved there from the route handler as "a NAICS domain fact, not a route concern."
  • Organisation -[:HAS_NAICS]-> NAICS carries no edge properties — a plain tag relationship.
  • The org-level and transaction-level HAS_AQUILA1 edges carry the same shape, { weight, for }for partitions the relationship into independent SPENT/INVESTED baskets that each normalize to their own weight total (the transaction backfill's ON CREATE SET r.weight = aq.weight, above, sets this same field on the transaction edge). The concordance edge (NAICS -> Aquila1) is the exception: it carries only { for }, since a concordance mapping has no per-organisation weight of its own. (RECEIVED is reserved but not populated by the legacy importer.)

Repository Methods

MethodFileWhat it does
findByIdForAccount(id, accountId)organisation-repository.tsIDOR-safe read: visible if ownedBy is empty (global) or contains this account. Canonical account-scoped org read per this repo's security conventions.
connectNAICS(id, code, level)organisation-repository.tsRaw Cypher MERGE (not the GraphQL connect operator, which always compiles to CREATE and would let two concurrent adds of the same not-yet-attached code duplicate the edge — the TOCTOU race this method was introduced to close).
getAquila1Basket(organisationId, forType)organisation-repository.tsReads the org's current HAS_AQUILA1 edges for one basket. Also used by the legacy transaction importer for parent-basket inheritance.
backfillTransactionAquila1(input)organisation-repository.tsSee Transaction Backfill above.
findByCode(code)naics-repository.tsExact-match lookup used by the use case to validate the selected code exists.
findByNameAndLevel(name, level)naics-repository.tsTypeahead query, capped at NAICS_TYPEAHEAD_LIMIT (25) rows, sorted [name asc, code asc]code is the tiebreaker because many level-5 names repeat verbatim (e.g. "…, nec" leaves), and code is unique, so the truncated result window is deterministic.

Cache Invalidation

The write action calls revalidatePath('/organisations/{id}', 'layout') — refreshing the analyst page and spotlight scorecard, both server-rendered under that layout.

It deliberately does not revalidate /identify. The user typically adds from the identify sidebar while sitting on /identify/companies; a layout revalidation there would re-run getCompaniesData — the whole-account FindOrganisationsWithFootprint aggregation, multi-second on large accounts — just to flip one organisation's classified/unclassified partition. Instead:

  • The sidebar's own categorisation updates client-side, via a targeted refreshOrganisationCategorisation patch into the Zustand store (the store isn't reachable by the Router Cache anyway).
  • The companies table itself is dynamically rendered with no custom staleTimes, so the next navigation to /identify fetches fresh data regardless.
  • The one accepted lag: the companies table behind an already-open sidebar keeps showing the pre-add classification until the user navigates away and back.

Feature Flag

Key: FEATURE_FLAG_KEYS.MANUAL_NAICS_CLASSIFICATION = 'feature/manual-naics-classification' (packages/core/src/domain/constants/feature-flags.ts)

Checked independently in three places:

LayerReaderDefault
UI (three surfaces + action)manualNaicsClassification() from apps/web/src/flags/index.ts (flags/next + @flags-sdk/edge-config)false
Use case (AddOrganisationNAICS)getFeatureFlag(FEATURE_FLAG_KEYS.MANUAL_NAICS_CLASSIFICATION, false) from @repo/core's Edge Config readerfalse

Both readers ultimately read the same Edge Config value, so they can't diverge in production. For local development, both independently honor the same FEATURE_FLAG_OVERRIDES env var format (comma-separated key=true/false pairs), which is what keeps the UI-side and use-case-side checks in agreement without Edge Config configured locally.

Unlike most account-feature flags in this codebase (which default true), this one defaults off — its own JSDoc frames it explicitly as a kill switch: it's a new write path that mutates NAICS/Aquila1 edges and backfills transaction classifications, live across three surfaces at once, with no prior production track record.

The flag's current value is visible via the internal flag-values debugger (apps/web/src/flags/flag-values-debugger.tsx).


Known Limitations

  • No deletion / undo. Once added, a manual NAICS classification cannot be removed through the product.
  • No cross-repository transaction boundary. A failure partway through AddOrganisationNAICS.execute() can leave an org NAICS-tagged but Aquila1-unsynced. This self-heals on a sequential retry (see Self-Heal vs. Transaction Boundary) but is not automatically retried.
  • Concordance gaps are possible. A NAICS code with no SPENT/INVESTED concordance mapping produces aquila1Synced: false — the org is tagged, but footprint scores for its transactions remain null until the underlying concordance data is extended. This is logged server-side (logWarning) and surfaced softly in the UI, not treated as an error.
  • Stale companies table behind an open sidebar. See Cache Invalidation.
  • e2e coverage was scoped out. The feature ships with unit and integration test coverage; a previously-drafted end-to-end test suite (and test-only infrastructure added solely to support it) was removed from the PR as out-of-scope before merge.

On this page