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
- High-Level Architecture
- Scope and Constraints
- Frontend
- Backend
- Database Layer
- Cache Invalidation
- Feature Flag
- Known Limitations
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_LEVELlives inNAICSRepositoryas 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
profileStatushas settled (COMPLETEDorFAILED— seeisEnrichmentComplete) 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;initiallyUnclassifiedis 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
revalidatePaththat flipsinitiallyUnclassifiedtofalseserver-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
AddClassificationControlmounted at a stable JSX position, gated only onenrichmentComplete(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. handleAddedorder 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-specificonAddedcallback.- A
justAddedRef(a ref, not state) tracks whether an add has happened in this session, drivingAddNAICSSession'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 nokeyprop — both surfaces already remount the whole page/section on an organisation change (a full navigation to/organisations/[id]/analystor/organisations/[id]/spotlight), soAddClassificationControlitself 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:
AddNAICSButtonis loaded vianext/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 ononPointerEnter/onFocusof 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
justAddedprop istrueonly when a render is caused byAddClassificationControl's latch just firing (i.e., right after a successful add) — not on a plain first mount. AuseEffectkeyed onjustAddedrefocuses 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:
- A plain
mousedownlistener with DOM-containment checks against the wrapper — active only while the popup itself is closed (nothing has portaled yet). - Once the popup is open, dismissal is delegated to
SearchCombobox's ownonOpenChangereason, 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.
- A plain
handleComboboxOpenChangecollapses 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).
handleAddedcollapses 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). handleSearchfetchesGET /api/naics/search?term=...withcache: 'no-store', forwarding the combobox's ownAbortSignalso a superseded keystroke's in-flight request is actually cancelled, not just ignored.- Two independent post-submit UI states —
errorandsyncNotice— render into a single, always-mountedrole="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 softsyncNotice("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
SearchComboboxwithvariant="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.
| Surface | File | Data source | Refresh after add |
|---|---|---|---|
| Identify sidebar | sidebar-categorisation.tsx | Client-fetched into a Zustand store (useIdentifySidebarStore) | refreshOrganisationCategorisation — a targeted client-side patch (see Cache Invalidation) |
| Analyst page | .../analyst/_components/sections/classification.tsx | Server component, GetClassifications use case | revalidatePath RSC re-render only — no client callback needed |
| Spotlight scorecard | .../spotlight-scorecard/components/naics-classifications.tsx | Client component, organisation.aquila1 from parent | revalidatePath (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.tsx → company-detail-sidebar.tsx → sidebar-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:
| Variant | Used by | Notes |
|---|---|---|
default | — | Light input, light popup |
toolbar | — | Matches the dark search-bar/toolbar styling |
default-dark-popup | Manual NAICS add flow | Light 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/minCharsvia refs, not directly —useDebounceCallback(fromusehooks-ts) memoizes its debounce instance on[func, delay], so if the worker's identity changed every render (as it would with an inlineonSearchprop), 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
tokenRefcounter with anAbortController: 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.AbortErroris 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): belowMIN_TERM(2 chars) returns[]without querying (the combobox also enforcesminChars, this is defense-in-depth); aboveMAX_TERM(200 chars) returns400rather 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 carriesid,level, and timestamps, but onlyname/codeare 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/corecall entirely when the flag is off, saving a wasted round-trip. - Uses
ForbiddenError, notUnauthorizedError— this is a per-resource/feature-availability gate, not a session-level failure (mirrorssendEngagement/transactionUpload). aquila1Synced: falseon a successful response is logged vialogWarning(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:
- Acquire a per-organisation Redis lock (before any reads — see Concurrency).
- Read the org (
findByIdForAccount, IDOR-safe) and the target NAICS code, then connect it (connectNAICS, idempotentMERGE) unless the org already carries that exact code. - 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.
- Read both Aquila1 baskets (SPENT and INVESTED) after the sync.
- 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:
connectNAICSis 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 TransactionType — SPENT (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:
setAquilaunconditionally deletes bothHAS_AQUILA1andHAS_AQUILA2edges for the givenforTypebasket 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 ofsetAquila, 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:
- Matches the account's transactions linked to the org that don't already have a
HAS_AQUILA1 {for: transactionType}edge (WHERE NOT EXISTS {...}). - For each, unwinds the org's just-synced Aquila1 basket and
MERGEs aHAS_AQUILA1 {for: transactionType}edge withON CREATE SET r.weight = aq.weight. - 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 classificationNAICS.level— level 5 is the leaf (6-digit codes); this is the only level manual classification offers.NAICS_LEAF_LEVELlives inNAICSRepository, moved there from the route handler as "a NAICS domain fact, not a route concern."Organisation -[:HAS_NAICS]-> NAICScarries no edge properties — a plain tag relationship.- The org-level and transaction-level
HAS_AQUILA1edges carry the same shape,{ weight, for }—forpartitions the relationship into independent SPENT/INVESTED baskets that each normalize to their own weight total (the transaction backfill'sON 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. (RECEIVEDis reserved but not populated by the legacy importer.)
Repository Methods
| Method | File | What it does |
|---|---|---|
findByIdForAccount(id, accountId) | organisation-repository.ts | IDOR-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.ts | Raw 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.ts | Reads 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.ts | See Transaction Backfill above. |
findByCode(code) | naics-repository.ts | Exact-match lookup used by the use case to validate the selected code exists. |
findByNameAndLevel(name, level) | naics-repository.ts | Typeahead 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
refreshOrganisationCategorisationpatch 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/identifyfetches 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:
| Layer | Reader | Default |
|---|---|---|
| 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 reader | false |
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.