Data Model & Persistence
Every source the pipeline screens — sanctions lists, BHRRC, Phase-2 deep research, and the legacy engine — persists into the same Issue node shape, reconciled additively against whatever already exists for the organisation. This page covers the Neo4j schema, provenance fields, the corrected organisation-status facts, additive-merge reconciliation, concurrency correctness, article materialisation, and the read path. See Issue Research Pipeline for how persistence fits into the pipeline as a whole.
Neo4j schema
An Issue is a standalone node connected to its evidence (Article, via REFERENCES) and to the organisation(s) it is reported against (HAS_ISSUE, an edge carrying which ESG satellite the issue belongs to):
type Issue @node {
id: ID! @id
title: String!
text: String!
date: DateTime!
sentiment: Float!
involvement: IssueInvolvementType!
source: String
tier: IssueTier
slaveryPractice: SlaveryPractice
articles: [Article!]! @relationship(type: "REFERENCES", direction: OUT)
organisation: [Organisation!]!
@relationship(type: "HAS_ISSUE", direction: IN, properties: "IssueProperties")
}
type IssueProperties @relationshipProperties {
satelliteMode: SatelliteMode!
}| Field | Type | Notes |
|---|---|---|
id | ID! | Neo4j-generated. |
title | String! | Frozen once an incoming issue merges onto this node (see reconciliation). |
text | String! | Also frozen on merge. |
date | DateTime! | Derived from the consolidated issue's year/timeframe, falling back to a year embedded in a source URL, then "now" (yearToDate in helpers/consolidated-to-issue.ts). |
sentiment | Float! | A severity score; GetReportedIssues.deriveSeverity buckets it into low/medium/high/critical. |
involvement | IssueInvolvementType! | CAUSED_BY | CONTRIBUTED_TO | DIRECTLY_LINKED_TO — see Involvement Definitions. There is no NONE value on the Neo4j enum; a NONE-classified incoming issue is dropped before persistence, never written. |
source | String | Provenance tag — see below. |
tier | IssueTier | TIER_1 | TIER_2 — see below. |
slaveryPractice | SlaveryPractice | The specific practice type — see below. |
articles | [Article!]! | REFERENCES, outgoing. The article corpus behind an issue — see article materialisation. |
organisation | [Organisation!]! | HAS_ISSUE, incoming, edge-typed IssueProperties { satelliteMode: SatelliteMode! }. SatelliteMode is MODERN_SLAVERY | EMISSIONS | BIODIVERSITY; every native-pipeline write uses MODERN_SLAVERY only (SATELLITE_MODES in persist-issues-research.ts). |
Provenance fields
Three fields distinguish which pipeline authored an issue, and were added additively alongside the existing legacy schema — every field is nullable, so pre-existing issues and pre-existing reads are unaffected:
| Field | Meaning |
|---|---|
source | null for legacy/OpenAI-sourced issues. 'native' for Phase-2 deep research. 'bhrrc' or a sanctions-list tag ('ofac' / 'un-sc' / 'uk-sanctions' / 'uflpa' / 'bis-entity' / 'eu-sanctions' / 'dfat-sanctions') for Phase-1 screening sources. |
tier | TIER_1 (genuine modern slavery — passes the Modern Slavery Act 2018 s4 freedom test) or TIER_2 (broader worker/human-rights issue). Null for legacy issues. |
slaveryPractice | SlaveryPractice enum: the eight Modern Slavery Act s4 practices (HUMAN_TRAFFICKING, SLAVERY, SERVITUDE, FORCED_LABOUR, FORCED_MARRIAGE, DEBT_BONDAGE, DECEPTIVE_RECRUITING, WORST_FORMS_OF_CHILD_LABOUR), plus BROADER_WORKER_ISSUE — the enum's tier-2 label. It is defined but never actually persisted: the deterministic reduce/consolidation code (buildConsolidated in reduce-merge.ts, mirrored by sanctionsTierAttrs in sanctions-issue-attrs.ts) only ever carries a practice value across from a TIER_1 member. So slaveryPractice is always null for TIER_2 issues, as well as for legacy issues. |
source != null is also the predicate the reconciliation pool uses to select which existing issues are eligible merge targets — see below.
Organisation status fields
Organisation tracks the issues-generation phase with issuesStatus: EnrichmentPhaseStatus (PENDING \| COMPLETED \| FAILED) and issuesCheckedAt: DateTime (the timestamp of the most recent phase completion, success or failure). There is no issuesGeneratedAt field and no issue-count scalar anywhere on Organisation. The issue count is always computed live, via issuesConnection.totalCount (IssueRepository.countByOrganisationAndModule) — never read off a stored field.
Additive-merge reconciliation (never delete)
PersistIssuesResearch is the single write path every source funnels through — BHRRC and the sanctions profiles call it via PersistBhrrcIssues/directly, and Phase 2 calls it per cell. It never deletes or overwrites an existing Issue; a re-run can only add new Issue nodes or add new article REFERENCES to ones that already exist (ADR-0022).
- Drop and default. Incoming
ConsolidatedIssues withinvolvement === 'NONE'are filtered out before anything else (the Neo4j enum has noNONEvalue).sourcedefaults to'native'when the caller doesn't supply one. - Load the pool. The org's existing native-authored issues (
source != null) are read — either fetched fresh (loadIssueReconcilePool) or supplied by the caller as a shared per-phase pool (see concurrency). - Cluster.
clusterIssues(an LLM clustering call built frombuildReducePrompt) groupsexisting ∪ incomingrefs into clusters. - Decide per issue.
planReconcileturns each incoming issue's cluster membership into one of three verbs:
| Verb | Effect |
|---|---|
merge | Additive: the anchor's title/text/tier/involvement stay frozen; only the incoming issue's article REFERENCES are added, onto the oldest year-compatible existing cluster member (min createdAt, tied by nodeId). |
merge-incoming | The cluster has ≥2 incoming members but no existing match (e.g. two aliases of one sanctions designation on a first run) — every member after the earliest merges onto the earliest member's just-created node instead of each creating its own. |
create | A brand-new Issue node, connected via HAS_ISSUE(MODERN_SLAVERY). |
A deterministic year-compatibility guard (yearCompatible) prevents over-merging: two timeframes are compatible unless both carry known years that are disjoint — an unknown/absent year on either side never blocks a merge, but a clear year mismatch (e.g. a 2020 incident vs. a 2024 one) forces create instead of silently folding two different incidents together.
If the clustering call itself throws, reconciliation degrades additively, not silently: every incoming issue is created as new (never dropped), and a reconcile-clustering-failed: … entry is added to the returned errors so the degrade is operator-visible. The worst case under this design is a duplicate issue; a dropped one is never acceptable for a compliance screening pipeline (ADR-0022). A per-article idempotency backstop (IssueRepository.connectArticles) also holds regardless of clustering behaviour — it reads currently-connected article ids, diffs in JS, and connects only what's new in a single MERGE-backed mutation.
Concurrency correctness
Two mechanisms make the additive-merge design safe under the harness's actual concurrency, both described in ADR-0022:
IssueReconcilePool(helpers/issue-reconcile-pool.ts) is fetched once per phase — not once per persist — vialoadIssueReconcilePool, and threaded through every persist that phase enqueues (~2 reads per run, versus ~10 under the original per-persist re-fetch design). Each persist reconciles againstpool.rowsand callspool.add()for every issue it creates, so a later persist in the same phase sees an earlier persist's creates without re-reading Neo4j.createPersistChain(p-limit(1),helpers/serial-persist-chain.ts) serialises every persist within a phase into one queue: persists run one-at-a-time in enqueue order (so each can safely read-cluster-write against the pool's current state), while each source's own research still runs concurrently and only enqueues once it resolves. This is why Phase 1 surfaces issues source-by-source rather than all at once — sources finish research in parallel, but their persists land through the same serial gate.
The in-memory pool mutation is only safe because of that serialisation — no two persists in a phase ever touch pool.rows concurrently. Across phases the guarantee is different: Phase 2 loads its own fresh pool rather than reusing Phase 1's, because Phase 1 and Phase 2 are separate Inngest steps — a step retry re-runs the entire phase against current DB state (ADR-0022), so reusing another phase's in-memory pool would reconcile against a stale or foreign snapshot instead of what's actually in Neo4j when the step starts.
Article materialisation
MaterialiseArticlesFromChunks turns the native pipeline's own search chunks into Article nodes — there is no Diffbot extraction on the native path. For each chunk it calls CreateArticle with source: ArticleSource.Tavily and extract: false, falling back to the chunk's own title/content rather than re-fetching the page. It returns a url → articleId map, keyed by normalised URL, which the persist step (PersistIssuesResearch) uses to resolve each consolidated issue's source URLs into article ids for the REFERENCES connect. Per-chunk failures are isolated (Promise.all over try/catch per chunk) — one bad URL never drops the rest of the cell's article links.
Read path
| Use case / method | Purpose |
|---|---|
IssueRepository.findByOrganisationId (FindIssues) | Fetches every Issue linked to an organisation, with its articles and per-edge satelliteMode. Backs both the reconcile pool and GetReportedIssues. |
IssueRepository.countByOrganisationAndModule (CountIssues) | The authoritative live-count primitive — a single correlated issuesConnection.totalCount predicate on organisationConnection.some.{node.id, edge.satelliteMode}, so an issue linked to two organisations under different modules is never miscounted against the wrong (org, module) pair. |
GetReportedIssues | Maps Issue rows to a presentation shape (derived severity, modules from edge properties), optionally filtered by satelliteMode, and sorts by createdAt then id — a stable order that survives a mid-run reconcile-merge bumping an existing issue's updatedAt without creating a new node. |
GetLiveIssuesCount | The scorecard's live badge count while issuesStatus === PENDING and the native harness is on; reads countByOrganisationAndModule best-effort (returns 0 on failure or when not applicable). See Live Streaming for how this feeds the polling UI. |
GetOrganisationFlagsCount | Combines operational product-flag counts with the Modern Slavery issuesCount (via countByOrganisationAndModule) into the sidebar's aggregate flag badge. |