Fair Supply LogoFair Supply - Docs

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!
}
FieldTypeNotes
idID!Neo4j-generated.
titleString!Frozen once an incoming issue merges onto this node (see reconciliation).
textString!Also frozen on merge.
dateDateTime!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).
sentimentFloat!A severity score; GetReportedIssues.deriveSeverity buckets it into low/medium/high/critical.
involvementIssueInvolvementType!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.
sourceStringProvenance tag — see below.
tierIssueTierTIER_1 | TIER_2 — see below.
slaveryPracticeSlaveryPracticeThe 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:

FieldMeaning
sourcenull 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.
tierTIER_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.
slaveryPracticeSlaveryPractice 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).

  1. Drop and default. Incoming ConsolidatedIssues with involvement === 'NONE' are filtered out before anything else (the Neo4j enum has no NONE value). source defaults to 'native' when the caller doesn't supply one.
  2. 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).
  3. Cluster. clusterIssues (an LLM clustering call built from buildReducePrompt) groups existing ∪ incoming refs into clusters.
  4. Decide per issue. planReconcile turns each incoming issue's cluster membership into one of three verbs:
VerbEffect
mergeAdditive: 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-incomingThe 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.
createA 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 — via loadIssueReconcilePool, 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 against pool.rows and calls pool.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 / methodPurpose
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.
GetReportedIssuesMaps 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.
GetLiveIssuesCountThe 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.
GetOrganisationFlagsCountCombines operational product-flag counts with the Modern Slavery issuesCount (via countByOrganisationAndModule) into the sidebar's aggregate flag badge.

On this page