ADR-019: Report-Person Linking with Pluggable Jurisdictional Matching

On this page

Status

Accepted (2026-04-25). Implementation tracked in the report-person-linking plan. The status flip lands as Step 1 of that plan.

Related: ADR-003 (RabbitMQ topic exchange — wildcard audit subscriber consumes the new link/unlink/auto-link events), ADR-006 (zen-engine for rules — same engine consumes the new matching ruleset), ADR-008 (jurisdiction-configurable workflow — extends to person-matching), ADR-012 (service boundaries — matching layers honor existing borders), ADR-016 (event-driven convert flow — auto-link inserts join the same transaction), ADR-017 (reports-immutable-after-submission — preserved here), ADR-018 (edge-forwarder pattern — referenced for the BFF integration).

Context

Phase 1 of the family chain view (MR !153, feat/cases-person-filter-chain-view) added a ?person_id={uuid} filter to GET /v1/cases/referrals, GET /v1/cases/investigations, and GET /v1/cases/cases. Reports were intentionally left unfiltered: cases.reports.children and cases.reports.adults are typed-but-non-relational JSONB arrays with no FK back to cases.persons. The BFF (services/craig-web/src/routes/cases/chain.rs) currently pulls the most recent 100 reports for any chain view and surfaces a perf-note banner when that cap is hit — a known correctness gap acknowledged at line 17 of chain.rs.

Three forces push toward closing the gap with explicit linking rather than fuzz-matching at query time:

1. Reports are a regulated record, not a search-time blob

NCANDS export, audit, and chain visibility all want a deterministic answer to "which person is this child entry?" Computing that answer fresh on every chain view is wasteful, brittle, and produces non-deterministic results when person records get updated. The link belongs in the database.

2. ADR-017 froze the report body after submission

Reports are immutable after the partner submits them — no schema column ever lets a worker patch a child’s name on the report itself. That makes denormalizing person_id onto the JSONB blob a non-starter: it would require either re-opening the immutability rule or maintaining a parallel "person_id" sub-array that drifts from the typed shape. A separate linking table preserves both immutability and queryability.

3. Matching logic varies by jurisdiction along multiple axes

Different jurisdictions:

  • Weight signals differently (DOB exactness vs. trigram name similarity vs. address overlap).

  • Have different seed strategies. Sparse-case jurisdictions get most signal from the just-created referral’s allegations; dense-case jurisdictions get strong signal from a confirmed case’s household. Some have neither and rely on raw name search.

  • May prefer different name-similarity algorithms (trigram-Jaccard, Jaro-Winkler, Levenshtein-normalized) once enough deployment data exists to compare them.

Hard-coding any of these in cases reproduces the problem ADR-008 already solved for screening, safety, and timeliness: jurisdictional behavior should live in craig-rules data + ruleset metadata, not Rust source. The matcher must be pluggable per deployment without code changes.

Decision

Top-level shape

  1. New linking table cases.report_persons records confirmed FK relationships between a report’s children/adults JSONB entry (by index + role) and a persons.id. Confirmations may be system-written (linked_by IS NULL, the auto-link-at-convert path) or worker-written (linked_by = caseworker_sub, the manual confirm-suggestion path).

  2. Three-layer matching architecture separates pure signal computation, jurisdictional decision policy, and request orchestration:

    Layer Where it lives What it does

    L1 — Signals

    New shared crate craig-matching

    Pure compute. compute_signals(JsonbEntry, PersonRecord, &dyn NameSimilarity) → SignalSet. Deterministic, no I/O. Each signal is a named, range-bounded number or boolean (e.g., name_similarity_score: f32 ∈ [0,1], dob_exact_match: bool, dob_within_30_days: bool, address_overlap_score: f32, phone_match: bool, gender_match: bool). The name-similarity algorithm is injected via a NameSimilarity trait; v1 ships a TrigramJaccard impl backed by strsim.

    L2 — Decision

    rulesets/{jurisdiction}/{jurisdiction}-person-match.json (JDM, evaluated by craig-rules)

    SignalSet → MatchVerdict { score, confidence: AUTO | SUGGEST | REJECT, reasons: [String], ruleset_name, ruleset_version }. Same JDM evaluation pattern already used by safety-assessment, intake-screening, etc.

    L3 — Orchestration

    services/craig-cases (new endpoint + auto-link branch in convert_report)

    Reads ruleset metadata to learn (a) which seed_sources to assemble candidates from, (b) which name_similarity_algorithm to inject into L1. Pre-filter candidate persons via pg_trgm similarity (cheap), compute SignalSet per candidate, evaluate each via craig-rules with the deployed jurisdiction’s ruleset, return ranked candidates (suggestions endpoint) or auto-link the single AUTO-confidence candidate (convert-time path).

  3. Ruleset metadata extends with two declarations beyond the standard JDM nodes/edges:

    {
      "name": "georgia-person-match",
      "version": "v1.0",
      "description": "...",
      "metadata": {
        "seed_sources": ["allegations", "case_household"],
        "name_similarity_algorithm": "trigram_jaccard"
      },
      "nodes": [...],
      "edges": [...]
    }
    • seed_sources: [String] — bounded enum (allegations | case_household | future). The orchestrator dispatches one candidate-collector per declared source and unions the results before pre-filter. If a ruleset declares case_household but the API call doesn’t supply seed_case_id, that source contributes zero candidates (degraded mode, not an error — tracing::warn! once with the report id).

    • name_similarity_algorithm: String — bounded enum (trigram_jaccard in v1; future jaro_winkler etc.). The orchestrator selects the matching NameSimilarity impl from a registry; an unknown algorithm string is a startup-time error (caught by ruleset validation).

  4. craig-matching is a new crate, not an extension of craig-reference. Reasoning recorded in Alternatives considered; in short, craig-reference is reference data + AFCARS/NCANDS translators with no string-similarity dependencies, and matching logic is a different concern with different test surface. Adding it to craig-reference would dilute that crate’s identity.

  5. craig-matching depends on strsim = "0.11" directly rather than implementing trigram Jaccard by hand. strsim is already a transitive prod dep via both clap (→ craig-cli, craig-seed, xtask) and zen-engine (→ craig-rules), so promoting it to a direct dep adds zero supply-chain surface. Battle-tested for evidence-grade work; saves ~30 LOC of bespoke math + tests; makes the future jaro_winkler impl free (already in strsim).

  6. Hybrid population strategy:

    • Auto-link at convert — when POST /v1/cases/reports/{id}/convert runs, the orchestration layer assembles the candidate set per the ruleset’s seed_sources (allegations from the just-created referral, household members of seed_case_id if supplied), computes signals + verdicts, and INSERTs report_persons rows for AUTO verdicts in the same transaction as the referral creation. Anything SUGGEST or REJECT writes nothing.

    • Manual confirm-suggestion — a new internal endpoint GET /v1/cases/reports/{id}/person-suggestions returns ranked candidates per child/adult entry. The caseworker UI renders top N with score + reasons; on confirm it calls POST /v1/cases/reports/{id}/person-links. A "Link existing person" path lets a worker bypass suggestions entirely and pick a person via deterministic search (the existing /v1/cases/persons endpoint).

  7. Suggestions are not persisted. Only confirmations write report_persons rows. Rejections produce no audit row in the initial design — revisit only if rejection-replay becomes a real annoyance (see Open questions).

  8. Auto-link uses AUTO-confidence threshold only. A single unambiguous match writes the row; multiple AUTO candidates is a logic-error in the ruleset and the row is not written. The orchestration layer (a) emits tracing::warn! per ambiguity occurrence, AND (b) increments skipped_due_to_ambiguity in the case.report_persons_auto_linked event payload so the dual-emit gives both dev-loop signal (logs) and durable observability (audit_log via wildcard subscriber). Anything below AUTO stays as a manual suggestion.

  9. Scope of "person" for reports is child and adult. Reporters could be linkable too (use case: "show me everything Jane Doe has reported"), but reporters are a different access pattern with different authority semantics — deferred.

  10. Ruleset version is frozen on the row. When a report_persons row is written, the active (ruleset_name, ruleset_version) is stamped onto it. Subsequent ruleset changes do not re-evaluate or invalidate existing rows. Rationale: the linking decision is an audit-grade event; replaying it when the rules change would change history.

Schema

CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE report_persons (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    report_id       UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
    person_id       UUID NOT NULL REFERENCES persons(id),
    role            TEXT NOT NULL,           -- 'child' | 'adult' (extensible — TEXT for jurisdictional flexibility)
    jsonb_index     INT,                     -- which children[] / adults[] entry this links to (NULL allowed for forward-compat)
    linked_by       UUID,                    -- caseworker sub; NULL means system (auto-link at convert)
    linked_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    ruleset_name    TEXT,                    -- e.g. 'georgia-person-match' (NULL when worker bypassed matching via "link existing")
    ruleset_version TEXT,                    -- e.g. 'v1.0'
    UNIQUE (report_id, person_id, role)
);

CREATE INDEX idx_report_persons_person ON report_persons(person_id);
CREATE INDEX idx_report_persons_report ON report_persons(report_id);

-- Trigram index on the persons name field; supports the L3 candidate
-- pre-filter (`SELECT id FROM persons WHERE (first_name || ' ' || last_name) % $1`).
CREATE INDEX idx_persons_name_trgm
    ON persons USING GIN ((first_name || ' ' || last_name) gin_trgm_ops);

The migration filename is stamped at MR-creation time (today’s UTC date) per existing convention; verify against ls services/craig-cases/migrations/ | sort | tail -1 immediately before authoring the file. Do not pre-stamp far-future dates.

The convert flow at services/craig-cases/src/api/reports.rs::convert_report currently:

  1. Validates priority, fetches the report, fetches effective disposition, checks CONVERTIBLE_DISPOSITIONS.

  2. Idempotency-checks via referrals::find_by_intake_report_id.

  3. Calls referrals::create_referral_from_report in a single statement.

  4. Publishes case.report_converted + case.referral_created.

Step 3 grows into a transaction: create the referral and, for each report child/adult, run the L3 orchestration with the candidate set assembled per the ruleset’s seed_sources. AUTO-confidence verdicts INSERT report_persons rows in the same transaction. SUGGEST/REJECT verdicts are dropped silently — they reappear when the worker views the report in the chain UI and clicks "Suggest matches." The auto-link path is best-effort: an error from L1/L2/L3 logs a warning, returns 0 auto-links, and lets the referral creation transaction commit (the matcher is best-effort; the referral creation is mandatory).

ConvertReportRequest grows an optional seed_case_id: Option<Uuid> field. Rulesets that declare case_household in seed_sources use it; rulesets that don’t, ignore it. Missing seed_case_id against a case_household-declaring ruleset is degraded mode (zero contribution from that source), not an error.

Worker confirmation rows

Worker confirmation of a suggested match writes:

Field Value

linked_by

the worker’s keycloak sub

ruleset_name

the active jurisdiction’s {jur}-person-match

ruleset_version

the version reported by craig-rules at the time of the suggestion

Worker bypass via "Link existing person" picker writes:

Field Value

linked_by

the worker’s keycloak sub

ruleset_name

NULL

ruleset_version

NULL

NULL ruleset metadata is the audit signal that the link did not go through the matching engine — a worker explicitly chose this person.

Unlink hard-deletes the row and emits case.report_person_unlinked with full provenance:

{
  "report_id": "018f...",
  "person_id": "018f...",
  "role": "child",
  "unlinked_by": "00000000-0000-0000-0000-000000000001",
  "unlinked_at": "2026-04-25T15:33:21Z",
  "originally_linked_by": null,
  "originally_linked_at": "2026-04-25T15:30:11Z",
  "ruleset_name": "georgia-person-match",
  "ruleset_version": "v1.0"
}

The wildcard audit subscriber (per ADR-003 topology) writes that envelope into audit_log. Soft-delete was rejected — it duplicates audit infrastructure for a marginal "undo" benefit that doesn’t exist as a real workflow (the right UX for "I changed my mind" is "go back, confirm again," producing a fresh row stamped with the relinker’s identity).

Chain-view consequence

Once report_persons exists and is populated, the BFF’s chain handler (services/craig-web/src/routes/cases/chain.rs) calls GET /v1/cases/reports?person_id={id} (a new filter symmetrical to Phase 1’s referrals/investigations/cases filters). The unfiltered fallback path and its perf-note banner can be retired entirely. Reports without a report_persons row for any person never appear in any chain view — the link is the chain.

Alternatives considered

A. Overload allegations to carry the report linkage (rejected)

Allegations have a specific legal weight — they encode the substantive claim the case is built on (abuse_type, disposition, victim/perpetrator). Fanning them out to also describe "this report mentions this person" overloads the table with two unrelated meanings and makes audit trail muddier. Allegations also only fire post-screening, so reports that never become referrals would have nowhere to record their links.

B. Denormalize person_id onto the JSONB blob (rejected)

Adds a person_id field inside children[i] and adults[i]. Cheap to query, but:

  • Violates ADR-017’s "report body immutable after submission" rule — workers couldn’t fix a wrong link without writing to the report.

  • Loses linkage when the JSONB shape evolves (additional fields, reordering).

  • No clean place to record linked_by / linked_at / ruleset_name / ruleset_version without growing the JSONB into a sub-document, at which point you’ve reinvented the linking table inside JSON.

C. Fuzzy-only, no FK at query time (rejected)

The BFF runs name-fuzz against persons on every chain-view request, joining unfiltered. Cheap to ship, but:

  • Non-deterministic (results change as persons is updated).

  • No audit trail — no record of which report was matched to which person, or by what logic.

  • Re-runs the same expensive fuzzy match for every viewer.

  • Conflicts with ADR-012’s "trace decisions to source" principle.

D. Extend craig-reference with the matching crate (rejected)

craig-reference is the home for domain enums, FIPS codes, and AFCARS/NCANDS translators. Its identity is "static reference data + bidirectional code translations." Person matching is a different concern: depends on string-similarity, has its own test surface (signal tables, ruleset round-trips), and doesn’t translate enums. Bundling it would dilute craig-reference’s purpose. A new dedicated crate is cleaner and follows the existing pattern (one crate per concern: `craig-crypto, craig-signing, craig-store, etc.).

E. Compute report_persons rows lazily on first chain-view request (rejected)

The first viewer pays the cost; subsequent viewers benefit. But:

  • The first viewer’s bearer token determines what candidates the matcher sees, which couples authorization to data shape.

  • "First view" is implicit state — hard to reason about, harder to test.

  • Doesn’t help auto-link at convert, which is where the obvious system-side signal exists.

F. Hard-coded matcher in Rust (no jurisdiction pluggability) (rejected)

Cheaper to ship. Reproduces the exact problem ADR-008 + ADR-006 solved for the other five rulesets. Tests would have to live in the matcher crate; jurisdictional differences would require code branches. Existing pattern is clearly better.

G. Bespoke trigram Jaccard implementation in craig-matching (rejected)

Earlier draft argued for a hand-rolled Jaccard-of-bigrams to "minimize new deps." But strsim is already a transitive prod dep via clap and zen-engine; it appears in Cargo.lock four times today. Promoting to a direct dep adds zero supply-chain surface, and strsim covers Jaro-Winkler/Damerau-Levenshtein for free when a future ruleset declares a different name_similarity_algorithm. The "minimize new deps" principle still holds — it just doesn’t apply here, because nothing new is being introduced.

H. Full workflow-language config (Mistral-style) (rejected)

The seed-source variability across jurisdictions is real but bounded — three or four named sources, unioned. A workflow language would let an operator wire arbitrary DAGs of candidate-collectors per jurisdiction; that’s strictly more power than the problem needs and strictly more surface to test, document, and secure. The chosen design (a bounded enum in ruleset metadata, dispatched in the orchestrator) keeps the spirit of operator-configurable matching without taking on workflow-language complexity. Revisit only if a future jurisdiction needs a candidate-collector composition that isn’t expressible as "union these N sources."

Consequences

Positive

  • Chain view is correct, not best-effort. The perf-note banner retires once Phase 2 lands. Reports with no link don’t appear (correct), reports with a link always appear (correct).

  • Audit-grade evidence. Every link records who or what made it, when, and against what ruleset version. Forensic + regulatory + audit needs all met by one column set.

  • Pluggable per jurisdiction, on three independent axes. Same architecture as the other five rulesets, plus per-jurisdiction tuning of seed sources and similarity algorithm without Rust changes. Deploying for a new state is "drop a new ruleset file in rulesets/{jur}/`" — set its `seed_sources and name_similarity_algorithm to taste.

  • Auto-link wins are deterministic. When the system writes a row, it does so under exact-match thresholds with single-candidate disambiguation — no ambiguity. Multi-AUTO ambiguity is observable via both logs and the auto-link event payload.

  • Three-layer architecture isolates testability. Signal compute is pure-compute pure-test (with the NameSimilarity trait injectable for deterministic tests). Decision is JDM with the existing ruleset-discovery test. Orchestration is integration test territory.

  • Reports immutability preserved. No JSONB writes against the report body.

  • Generalizable. The signals/decision/orchestration split + ruleset-metadata seed-source declaration is a pattern that applies to any future "match X to Y under jurisdictional rules" question (deduplication, sibling-grouping, reporter-to-person, screening-allowlists, etc.). Issues #212 and #213 are explicitly intended to follow this pattern in their own plans, not to fold into this one.

Negative

  • One more shared crate. craig-matching adds workspace surface. Mitigated by keeping it tightly scoped (signal compute only — no I/O, no DB).

  • One more JDM ruleset per jurisdiction, with extended metadata. Each jurisdiction grows from 5 to 6 rulesets. Ruleset validation in pre-push has to accept the new metadata block (additive — existing rulesets without it remain valid).

  • pg_trgm is a new Postgres dependency in the cases schema. It’s a contrib module shipped with Postgres — no new infrastructure, just a CREATE EXTENSION in the migration. Documented in the local-dev guide.

  • Auto-link transaction footprint grows. convert_report now runs report_persons inserts inside the referral creation transaction. Bounded — there’s at most one row per child/adult, plus the candidate queries are pre-filtered via the trgm index. Best-effort fallback prevents matcher errors from rolling back the referral.

  • Worker UI surface grows. A "Suggest matches" affordance per child/adult row, plus a confirm/unlink UI. Tracked in the implementation plan.

Neutral

  • Reporter linking is not in scope. Different access pattern, different authority semantics, easy to add later via the same table (role = 'reporter'). Out-of-scope for v1.

  • Rejected suggestions aren’t recorded. Initial design only persists confirmations. If rejection-replay becomes a friction point, the same table grows a rejected_at column — additive change.

  • Issues #212 (jurisdiction-configurable actor_role / disposition_kind allowlists) and #213 (jurisdiction-configurable screening allowlists) are deliberately separate work streams. They share the architectural pattern this ADR establishes (signals → JDM → orchestration with ruleset metadata) but live in different domains (screening decisions vs. person matching) with different consumers. Their own plans, when written, should reference this ADR as prior art for the metadata-extension technique.

Open questions (deferred to implementation)

  1. Auto threshold tuning per jurisdiction. Georgia’s initial threshold (exact name match + exact DOB) is conservative. Real-deployment data may push toward "exact name + DOB-within-30-days" or similar relaxations. The ruleset is the right place for this; document the tuning policy in the ruleset’s description field.

  2. pg_trgm index maintenance cost at scale. The seed dataset has 50 persons; production will have 5k–500k+. Trigram-index maintenance is well-understood but should be benchmarked. The plan adds a k6 scenario tests/k6/scenarios/person-suggestions-bench.js that seeds N persons and measures p95/p99 latency on the suggestions endpoint, run via cargo xtask perf --scenario person-suggestions-bench.

  3. Multi-row-per-(report, person) under role evolution. The UNIQUE (report_id, person_id, role) constraint allows the same person to be linked twice if they appear once as a child and once as an adult on the same report (rare but legal — e.g., a 17-year-old caregiver). Confirm this is the desired semantics with practitioners before locking schema.

  4. Should worker-bypass "link existing person" still record ruleset_name? Spec above says NULL. Alternative: stamp the active ruleset’s name+version anyway, with a flag column indicating worker-bypass. Defer to UI design — the simpler audit story (NULL = no rules ran) wins unless a regulatory requirement surfaces.

  5. Future seed sources beyond allegations and case_household. Examples: prior-report co-occurrence, partner-vendor identity assertion. New sources land as new enum variants + new collector functions in the orchestrator; rulesets opt in by adding the variant to seed_sources.

Implementation scope (sketch — detailed plan is a separate artifact)

This ADR is paired with the report-person-linking plan. Approximate rollout order:

  1. Plan + ADR-Accept + GitLab issue tree (this commit).

  2. craig-matching crate with NameSimilarity trait + TrigramJaccard impl (strsim) + signal compute + unit tests.

  3. georgia-person-match JDM ruleset with seed_sources + name_similarity_algorithm metadata + ruleset-discovery coverage. (Texas variant follows but is not a v1 blocker.)

  4. cases.report_persons table + pg_trgm extension migration (timestamp stamped at MR time).

  5. Orchestration layer in craig-cases (services/craig-cases/src/matching/) — suggestion endpoint, link/unlink endpoints, candidate pre-filter, ruleset-metadata-driven seed dispatch + similarity-algorithm registry, k6 perf scenario.

  6. Auto-link branch in convert_report with seed_case_id extension + case.report_persons_auto_linked event (incl. skipped_due_to_ambiguity).

  7. ?person_id= filter on GET /v1/cases/reports — symmetrical to Phase 1’s other endpoints.

  8. craig-web BFF — chain handler retires the unfiltered fallback; new "Suggest matches" UI on report detail. Manifest extension contingency lives here.

  9. Plan completion audit + archive.

Each lands as its own MR. The orchestration layer (5) is a hard prerequisite for the auto-link branch (6); the BFF retire (8) depends on (7).

Edit this page · latest