Plan: Report-Person Linking with Pluggable Jurisdictional Matching

On this page

Status

Step Description Status

1

Plan + ADR-019 accept + GitLab issue tree + nav.adoc Active entry

Done (2026-04-26) — MR !154

2

craig-matching crate — NameSimilarity trait + TrigramJaccard (strsim) + signal compute + unit tests

Done (2026-04-26) — MR !155, closes #217

3

georgia-person-match JDM ruleset (with metadata.seed_sources + metadata.name_similarity_algorithm) + ruleset-discovery coverage

Done (2026-04-26) — MR !156, closes #218

4

cases.report_persons table + pg_trgm extension migration + store layer

Done (2026-04-27) — MR !157, closes #219

5

craig-cases orchestration: suggestions + link/unlink endpoints + candidate pre-filter + ruleset-metadata-driven seed dispatch + similarity-algorithm registry + k6 perf scenario

Done (2026-04-27) — MR !158, closes #220

6

craig-cases auto-link branch in convert_report (sequential per errata E-01, seed_case_id optional, ambiguity-aware event payload)

Done (2026-04-27) — MR !159, closes #221

7

?person_id= filter on GET /v1/cases/reports (symmetrical to Phase 1)

Done (2026-04-27) — MR !160, closes #222

8

craig-web BFF — retire unfiltered fallback + perf-note banner; add "Suggest matches" UX on report detail

Done (2026-04-28) — MR !161, closes #223

9

Plan completion audit + archive

Done (pre-ADR-030) — this MR, closes #224

Issues: #217–#224 (filed in Step 1) — all closed via the MRs above.
Branch prefix: feat/report-person-linking- (Step 9 branch is chore/…​).
*Milestone
: 2026 Q3 — Feature Initiatives (id 7392240, iid 3)
Epic: not filed — the GitLab bot lacked group-level epic-create permission at Step 1; cross-references were threaded through the issue descriptions instead.

Context

Phase 1 of the family chain view shipped as MR !153 (feat/cases-person-filter-chain-view). It added ?person_id={uuid} to GET /v1/cases/referrals, /v1/cases/investigations, and /v1/cases/cases. Reports were left unfiltered because cases.reports.children and cases.reports.adults are JSONB arrays with no FK to cases.persons — the BFF (services/craig-web/src/routes/cases/chain.rs) currently pulls the most recent 100 reports unfiltered and surfaces a perf-note banner when the cap is hit. The comment at chain.rs line 17 names the planned fix: a report_persons linking table.

This plan implements that fix per ADR-019. The decisions pinned in the ADR (linking table over JSONB denormalization, three-layer architecture, hybrid auto+manual population, ruleset-version freeze on row write, scope = children + adults only, auto-link uses AUTO threshold only with multi-AUTO ambiguity dual-emit, suggestions not persisted, ruleset metadata declares seed_sources + name_similarity_algorithm, hard-delete unlink with full-provenance event, k6 perf scenario over bash fixture, module path matching/ not person_matching/) are the design baseline — this plan converts them to executable steps.

The 2026-04-25 baseline is 1197 Rust tests, 184 E2E tests, 28 vitest, 27 pytest. This plan adds an estimated 50–60 new Rust tests (10 unit in craig-matching, ~5 ruleset-eval, ~25 integration in craig-cases, ~10 BFF integration in craig-web) and 3 new E2E specs (suggestions render, confirm-link, auto-link-at-convert). No existing test should require modification; the existing chain-view E2E will need updating to assert the new "Suggest matches" affordance and the absence of the perf-note banner once a worker confirms a link.

Related ADRs:

  • ADR-019 — this plan’s parent

  • ADR-017 — established cases as the reports owner; reports-immutable invariant honored here

  • ADR-016 — convert_report stays in cases; auto-link inserts join its transaction

  • ADR-012 — service boundaries; matching layers honor them

  • ADR-006 — zen-engine; same pattern reused

  • ADR-008 — jurisdiction-configurable workflow

  • ADR-003 — topic exchange that the wildcard audit subscriber consumes (link/unlink/auto-link events)

Scope

In scope:

  • New shared crate craig-matchingNameSimilarity trait + TrigramJaccard impl (strsim) + signal-compute helpers, no I/O, no DB

  • New JDM ruleset family {jurisdiction}-person-match (Georgia variant only in v1; Texas is mechanical follow-up). Both metadata.seed_sources and metadata.name_similarity_algorithm are required fields.

  • New table cases.report_persons + pg_trgm extension + name-trigram index on persons

  • Suggestion endpoint GET /v1/cases/reports/{id}/person-suggestions

  • Link/unlink endpoints POST/DELETE /v1/cases/reports/{id}/person-links

  • ?person_id={uuid} filter added to GET /v1/cases/reports

  • Optional seed_case_id: Option<Uuid> field added to ConvertReportRequest

  • Auto-link branch inside convert_report (transactional, best-effort)

  • k6 perf scenario tests/k6/scenarios/person-suggestions-bench.js wired into cargo xtask perf

  • craig-web BFF: chain handler retires the unfiltered reports fallback and the perf-note banner; report-detail page gains "Suggest matches" UI per child/adult entry

  • tools/craig-seed/src/manifest.rs extension iff the chain-view UI spec needs report_persons IDs (Step 8 contingency)

  • End-to-end happy-path coverage and documented sad-path failures

Out of scope (tracked separately if adopted):

  • Texas variant of texas-person-match.json — landing this is a one-MR follow-up after the Georgia variant proves the schema; not blocking

  • Reporter linking (role = 'reporter') — different access pattern, deferred

  • Rejected-suggestion persistence — defer; revisit only if rejection-replay becomes a real friction point

  • Person deduplication / merging using the same matcher — separate plan; same crate could later host

  • Issues #212 and #213 (jurisdiction-configurable screening / actor_role / disposition_kind allowlists) — explicitly separate work streams. Same architectural pattern (signals → JDM → orchestration with ruleset metadata) but different domain (screening decisions vs. person matching), different consumers (caseworker UI vs. system-side auto-link), different timeline. The pattern this plan establishes generalizes; the #212/#213 plans, when written, should reference this one as prior art for ruleset-metadata extension. Argued: separate.

  • Issues #199 (standalone deployment), #172 / #163 (AWS deployment), #162 (autonomous IV-E exchange), #161 (ACF-199 reporting), #148 (multi-language), #215 (local-outbox, ADR-017 OQ#1) — orthogonal; listed here for completeness only

  • Local-outbox (#215) on the matching path — not relevant; matching is only invoked from caseworker-authenticated flows or from convert_report which is already cases-internal

  • Standalone-mode coverage — craig-intake is a stateless edge per ADR-017 and never invokes matching directly; the orchestration layer lives in cases

Design

Three-layer architecture summary

Layer Where it lives Public API surface

L1 — Signals

crates/craig-matching

pub trait NameSimilarity { fn score(&self, a: &str, b: &str) → f32; } (impls implement Send + Sync). pub struct TrigramJaccard; — backed by strsim::sorensen_dice or equivalent. pub fn compute_signals(entry: &JsonbEntry, person: &PersonRecord, sim: &dyn NameSimilarity) → SignalSet. SignalSet is serde::Serialize so it round-trips into the L2 input.

L2 — Decision

rulesets/{jur}/{jur}-person-match.json evaluated by craig-rules. Ruleset declares metadata.seed_sources: [String] and metadata.name_similarity_algorithm: String.

JDM emits { confidence: 'AUTO' | 'SUGGEST' | 'REJECT', reasons: [String] }. Numeric score is computed downstream by the orchestration layer (Step 5) from confidence + the input name_similarity_score — zen-engine v0.54 truncates fractional decision-table number outputs to int, so per-rule scores can’t be expressed in JDM cleanly. The full MatchVerdict { score: f32, confidence, reasons, ruleset_name, ruleset_version } is assembled in Rust.

L3 — Orchestration

services/craig-cases/src/matching/ (new module — pinned to matching/, not person_matching/; service prefix disambiguates against placement’s matching/).

pub async fn rank_candidates(report: &Report, role: &str, jsonb_index: usize, db, rules_client, jurisdiction, seed_case_id: Option<Uuid>) → Vec<RankedCandidate> and pub async fn auto_link_report_persons(tx, report, candidates_by_entry, ruleset_name, ruleset_version) → Result<AutoLinkOutcome>. Internally reads ruleset metadata to (a) dispatch per declared seed_sources, (b) pick the NameSimilarity impl from a registry. Called from suggestion endpoint and from convert_report.

craig-matching crate API

// crates/craig-matching/src/lib.rs

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct JsonbEntry {
    pub first_name: String,
    pub last_name: String,
    pub date_of_birth: Option<NaiveDate>,
    pub dob_approximate: bool,
    pub gender: Option<String>,
    pub phone: Option<String>,        // Adult only — None for Child entries
    pub address: Option<String>,      // Optional, future-extensible
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PersonRecord {
    pub id: Uuid,
    pub first_name: String,
    pub last_name: String,
    pub date_of_birth: Option<NaiveDate>,
    pub gender: Option<String>,
    // future: addresses (separate table), phone, etc.
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SignalSet {
    pub name_exact_match: bool,
    pub name_similarity_score: f32,   // [0.0, 1.0] — algorithm chosen by ruleset
    pub dob_exact_match: bool,
    pub dob_within_30_days: bool,
    pub dob_year_match: bool,
    pub gender_match: bool,
    pub phone_match: bool,            // false when one side is None
    // address_overlap_score reserved for future; v1 returns 0.0 always
}

pub trait NameSimilarity: Send + Sync {
    /// Returns a similarity score in [0.0, 1.0].
    fn score(&self, a: &str, b: &str) -> f32;

    /// Stable string identifier — used by the orchestrator's registry lookup
    /// and reported back in `MatchVerdict.reasons` for transparency.
    fn algorithm_name(&self) -> &'static str;
}

pub struct TrigramJaccard;
impl NameSimilarity for TrigramJaccard { ... }

pub fn compute_signals(
    entry: &JsonbEntry,
    person: &PersonRecord,
    sim: &dyn NameSimilarity,
) -> SignalSet { ... }

Direct dep: strsim = "0.11" (already a transitive prod dep via clap and zen-engine; promoting to direct is supply-chain-neutral). No bespoke math.

{jurisdiction}-person-match ruleset shape

Georgia v1 (rulesets/georgia/georgia-person-match.json) follows the existing 5-domain pattern: name, version, description, nodes (single decisionTableNode), edges, plus a new top-level metadata block:

{
  "name": "georgia-person-match",
  "version": "v1.0",
  "description": "...",
  "metadata": {
    "seed_sources": ["allegations", "case_household"],
    "name_similarity_algorithm": "trigram_jaccard"
  },
  "nodes": [...],
  "edges": [...]
}

Inputs map 1:1 to SignalSet fields (note: the JDM input is name_similarity_score, not a per-algorithm name); outputs are confidence, score, reasons. hitPolicy: "first". Initial Georgia thresholds:

  • confidence = "AUTO" only when name_exact_match == true && dob_exact_match == true && (gender_match == true OR gender = NULL on either side).

  • confidence = "SUGGEST" when (name_exact_match == true || name_similarity_score >= 0.85) && (dob_exact_match || dob_year_match || dob_approximate on the report side).

  • Everything else falls through to "REJECT".

Texas variant deferred; same structure with looser thresholds (Texas’s intake patterns differ — see .claude/docs/rulesets.md table for the existing per-jurisdiction differences).

Schema

-- services/craig-cases/migrations/<TIMESTAMP>_report_persons.sql
-- TIMESTAMP stamped at MR time. Verify against
--   ls services/craig-cases/migrations/ | sort | tail -1
-- before writing the filename. Latest at planning time was
-- 20260424110000_report_attachments.sql; pick today's UTC date in
-- YYYYMMDDHHMMSS form. Do NOT pre-stamp far-future dates — collisions
-- surface in the contributor's pre-push reseed and devstack_guard.

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,
    jsonb_index     INT,
    linked_by       UUID,
    linked_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    ruleset_name    TEXT,
    ruleset_version TEXT,
    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);

CREATE INDEX idx_persons_name_trgm
    ON persons USING GIN ((first_name || ' ' || last_name) gin_trgm_ops);

Orchestration: candidate pre-filter

SELECT id, first_name, last_name, date_of_birth, gender FROM persons WHERE (first_name || ' ' || last_name) % $1 ORDER BY similarity(first_name || ' ' || last_name, $1) DESC LIMIT 25 — the trgm GIN index makes this index-backed. $1 is the JSONB entry’s format!("{} {}", first_name, last_name). The % operator threshold defaults to 0.3 (Postgres default pg_trgm.similarity_threshold).

Pre-filter is the floor. Above it, the orchestrator unions in candidates from each declared seed_source (allegations victim/perp persons, case_household members) before deduplicating by person_id.

Seed-source dispatch

// services/craig-cases/src/matching/seed.rs (new)

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SeedSource {
    Allegations,
    CaseHousehold,
    // Add new variants here as future ruleset versions declare them.
}

pub async fn collect_candidates(
    sources: &[SeedSource],
    ctx: &SeedContext<'_>,
) -> Result<Vec<PersonRecord>> {
    let mut buf = Vec::new();
    for src in sources {
        match src {
            SeedSource::Allegations => {
                if let Some(referral_id) = ctx.referral_id {
                    buf.extend(store::persons::list_by_referral_allegations_in_tx(ctx.tx, referral_id).await?);
                }
            }
            SeedSource::CaseHousehold => {
                match ctx.seed_case_id {
                    Some(case_id) => {
                        buf.extend(store::persons::list_household_by_case_in_tx(ctx.tx, case_id).await?);
                    }
                    None => {
                        tracing::warn!(report_id = %ctx.report_id, "ruleset declared case_household seed source but seed_case_id not supplied; contributing zero candidates");
                    }
                }
            }
        }
    }
    Ok(dedup_by_id(buf))
}

Unknown variants in the ruleset’s seed_sources list are caught by ruleset validation at startup (added to cargo xtask validate) — not at request time.

Similarity-algorithm registry

// services/craig-cases/src/matching/similarity.rs (new)
use craig_matching::{NameSimilarity, TrigramJaccard};

pub fn select(algorithm: &str) -> Result<&'static dyn NameSimilarity> {
    match algorithm {
        "trigram_jaccard" => Ok(&TRIGRAM),
        other => Err(anyhow!("unknown name_similarity_algorithm: {other}")),
    }
}

static TRIGRAM: TrigramJaccard = TrigramJaccard;

The registry is intentionally explicit (no proc-macro or inventory crate) — five lines of code per future algorithm; new algorithms ship as new craig-matching impls.

Suggestion endpoint contract

Request:

GET /v1/cases/reports/{id}/person-suggestions
Authorization: Bearer <caseworker-or-above>

Response (200):

{
  "report_id": "018f1234-...",
  "ruleset_name": "georgia-person-match",
  "ruleset_version": "v1.0",
  "children": [
    {
      "jsonb_index": 0,
      "entry_label": "Jane Doe (DOB 2018-04-12)",
      "candidates": [
        {
          "person_id": "018f5678-...",
          "person_label": "Jane Doe (DOB 2018-04-12)",
          "score": 0.95,
          "confidence": "AUTO",
          "reasons": ["name_exact_match", "dob_exact_match", "gender_match"]
        }
      ]
    }
  ],
  "adults": [...]
}

Top N defaults to 5 candidates per entry; ?candidates_per_entry=10 overrides up to a hard cap of 25 (matching the pre-filter LIMIT). Already-linked entries are surfaced with a single candidate flagged "already_linked": true so the UI can render "Linked to Jane Doe — Unlink" rather than "Suggest matches."

POST /v1/cases/reports/{id}/person-links
Body: { "person_id": "...", "role": "child" | "adult", "jsonb_index": 0, "ruleset_version": "v1.0" }

Returns 200 with the inserted row, 409 if the (report_id, person_id, role) tuple already exists, 400 if role is not in {'child', 'adult'}. ruleset_version echoed back from the suggestions response so the row stamp matches the suggestion the worker confirmed.

DELETE /v1/cases/reports/{id}/person-links/{link_id}

Returns 200 with the removed row, 404 if not found. Authz: caseworker_or_above. Hard-delete (not soft-delete); the unlink event carries full provenance (see Audit + events below) so the audit trail is preserved through the wildcard subscriber.

ConvertReportRequest extends with an optional seed_case_id: Option<Uuid> field. Today’s convert_report (services/craig-cases/src/api/reports.rs lines 556–641):

let referral = store::referrals::create_referral_from_report(...).await?;
events::publish_report_converted(...).await;
events::publish_referral_created(...).await;
Ok(Json(referral))

After Step 6:

let mut tx = app.db.begin().await?;
let referral = store::referrals::create_referral_from_report_in_tx(&mut tx, ...).await?;

let outcome = matching::auto_link_report_persons(
    &mut tx,
    &report,
    referral.id,
    body.seed_case_id,    // None → degraded mode for case_household-declaring rulesets
    &rules_client,
    &jurisdiction,
).await.unwrap_or_else(|e| {
    // Best-effort: matcher failure must not roll back the referral creation.
    tracing::warn!(error = ?e, report_id = %report.id, "auto-link failed; continuing");
    matching::AutoLinkOutcome::default()
});

tx.commit().await?;

events::publish_report_converted(...).await;
events::publish_referral_created(...).await;
events::publish_report_persons_auto_linked(&publisher, id, &outcome).await;

Ok(Json(referral))

auto_link_report_persons only INSERTs rows whose verdict is AUTO; SUGGEST/REJECT verdicts are dropped (re-derivable on demand from the suggestions endpoint). Multi-AUTO ambiguity per entry: drop the entry, increment outcome.skipped_due_to_ambiguity, log a tracing::warn! per occurrence.

AutoLinkOutcome shape:

#[derive(Debug, Default, Serialize)]
pub struct AutoLinkOutcome {
    pub linked: Vec<ReportPerson>,
    pub skipped_due_to_ambiguity: u32,
}

?person_id= filter on GET /v1/cases/reports

Mirrors the Phase 1 pattern. The store-layer SQL:

SELECT r.* FROM reports r
WHERE ($1::UUID IS NULL OR r.id IN (
    SELECT report_id FROM report_persons WHERE person_id = $1
))
ORDER BY r.received_at DESC
LIMIT $2 OFFSET $3

Total count uses the same WHERE clause; pattern matches the existing referrals/investigations/cases handlers added in MR !153.

craig-web BFF retire of fallback

services/craig-web/src/routes/cases/chain.rs — current code (line 158–171):

  1. reports_path builds an unfiltered query with no person_id.

  2. perf_note_visible = reports.total > CHAIN_PAGE_SIZE as i64 (line 249).

After Step 8:

  1. reports_path includes &person_id={id}.

  2. perf_note_visible retired (set always-false or removed entirely; template branch deleted).

Report-detail template (services/craig-web/templates/intake/report_detail.html) gains a per-row "Suggest matches" htmx button per child/adult entry. Click → htmx GET /cases/reports/{id}/person-suggestions/partial (BFF route) → renders a fragment with the top candidates → confirm via POST /cases/reports/{id}/person-links (BFF route) → htmx swap shows "Linked to <person>" with an Unlink button.

Audit + events

New events on craig.events (consumed by the existing wildcard audit subscriber per ADR-003 topology):

  • case.report_person_linked — body { report_id, person_id, role, jsonb_index, linked_by, ruleset_name, ruleset_version, linked_at }. Fires from both auto-link and manual-confirm paths.

  • case.report_person_unlinked — full provenance: { report_id, person_id, role, unlinked_by, unlinked_at, originally_linked_by, originally_linked_at, ruleset_name, ruleset_version }. The orchestrator reads the row before deletion to populate the originally_* and ruleset fields; the wildcard audit subscriber writes this into audit_log for forensic replay.

  • case.report_persons_auto_linked — fires once per convert, body { report_id, count, skipped_due_to_ambiguity }. Lets reporting/observability count both auto-link efficacy and ruleset-quality issues without scanning the table.

Multi-AUTO ambiguity is dual-emitted: tracing::warn! per occurrence (dev-loop signal) and skipped_due_to_ambiguity in the convert event payload (durable observability via audit_log).

Steps

Each step is one logical unit shippable as its own MR. Sequencing dependencies are explicit per step.

Step 1 — Plan + ADR-019 accept + GitLab issue tree

Files: this plan file, docs/modules/ROOT/pages/adrs/adr-019-report-person-linking.adoc (Accepted), docs/modules/ROOT/nav.adoc (Active entry + ADR-019 entry).

  1. Land ADR-019 with Status: Accepted (this commit).

  2. Add this plan to nav.adoc under ** Active. Add ADR-019 to the ADR list.

  3. Create parent GitLab epic for report-person-linking, milestone 2026 Q3 — Feature Initiatives.

  4. Create one GitLab issue per Step 2–9. Each:

    • Type prefix: feat: for 2–8, chore: for 9

    • Links back to this plan

    • Labels: priority + service tags (see GitLab issues to file section below)

    • Milestone: 2026 Q3 — Feature Initiatives

    • Linked to parent epic via epic_id API field

  5. Update CLAUDE.md Phase Status table reference (last MR per git-workflow.md multi-MR rule — defer to Step 9).

Acceptance criteria:

  • cargo xtask check-docs passes — Tier 1 docs untouched, ADR + plan linked in nav.adoc.

  • glab issue list --milestone "2026 Q3 — Feature Initiatives" shows 8 new issues + parent epic + the existing #212/#213.

  • No code changes in this MR (docs + ADR only).

Verification:

cargo xtask check-docs
glab issue list --milestone "2026 Q3 — Feature Initiatives"

Dependencies: none. Blocks: Steps 2–9.

Step 2 — craig-matching crate

Files: crates/craig-matching/Cargo.toml (new — strsim = "0.11" direct dep), crates/craig-matching/src/lib.rs (new), crates/craig-matching/src/types.rs (new — JsonbEntry, PersonRecord, SignalSet), crates/craig-matching/src/similarity.rs (new — NameSimilarity trait + TrigramJaccard impl), crates/craig-matching/src/signals.rs (new), crates/craig-matching/tests/signals.rs (new), crates/craig-matching/tests/similarity.rs (new), workspace Cargo.toml members list.

  1. Create the crate. SPDX header on every file. Workspace member.

  2. Implement NameSimilarity trait + TrigramJaccard impl backed by strsim (Sørensen-Dice over bigram sets is the canonical "trigram-Jaccard-style" name match; pick strsim::sorensen_dice for stability or wrap strsim to compute Jaccard explicitly — record decision in MR description). algorithm_name() returns "trigram_jaccard".

  3. Implement compute_signals(entry, person, sim) → SignalSet:

    • name_exact_match — case-insensitive whole-name compare after trim.

    • name_similarity_scoresim.score(full_name(entry), full_name(person)); bounded 0.0..=1.0.

    • dob_exact_match / dob_within_30_days / dob_year_matchchrono::NaiveDate math; both sides must be Some(…​).

    • gender_match — case-insensitive equality after trim; false when either side is None.

    • phone_match — strip non-digits and compare last 10 digits; false when either side is None.

  4. Tests (12+, 100% happy + sad coverage per testing.md):

    • Happy: name_exact_match true on identical names.

    • Happy: name_similarity_score ≥ 0.7 on "Jane Doe" vs "Jayne Doe"; ≤ 0.5 on "Jane Doe" vs "John Smith"; bounded in [0.0, 1.0] on a fuzz of random ASCII strings.

    • Happy: dob_exact_match true on identical dates; dob_within_30_days true on dates 5/29 days apart and false on 31; dob_year_match true on same year different month.

    • Happy: phone_match strips formatting; "(404) 555-1212" matches "404-555-1212".

    • Happy: TrigramJaccard::algorithm_name() returns the literal "trigram_jaccard" (registry stability test).

    • Sad: empty strings, leading/trailing whitespace, mixed case all return well-formed (no panics, no NaN).

    • Sad: gender_match is false when either side is None.

    • Sad: phone_match is false when either side is None or stripped to <10 digits.

    • Sad: compute_signals with both dob None returns dob_exact_match=false && dob_within_30_days=false && dob_year_match=false (no false positives).

    • Property test (proptest, optional): compute_signals is total — never panics on any combination of `Option`s.

  5. Public API doc-comments per docs.rs convention.

Acceptance criteria:

  • cargo nextest run -p craig-matching — 12+ tests pass, 100% happy + sad coverage per testing.md.

  • cargo doc -p craig-matching clean.

  • cargo clippy -p craig-matching — -D warnings clean.

  • No I/O in the crate (no tokio, no sqlx, no reqwest in Cargo.toml).

  • strsim = "0.11" declared as a direct dep.

Verification:

cargo nextest run -p craig-matching
cargo clippy -p craig-matching --all-targets -- -D warnings
cargo doc -p craig-matching --no-deps

Dependencies: Step 1. Independent of Steps 3–8 except: Step 5 imports the crate.

Step 3 — georgia-person-match JDM ruleset (with metadata)

Files: rulesets/georgia/georgia-person-match.json (new), services/craig-rules/tests/ruleset_discovery.rs (existing — extends automatically; may need a fixture for the new metadata block), xtask ruleset validator (extend to recognize metadata.seed_sources + metadata.name_similarity_algorithm and reject unknown variants).

  1. Author the JDM file per the structure above. Single decisionTableNode, hitPolicy: "first". Inputs map 1:1 to SignalSet fields. Outputs are confidence (AUTO/SUGGEST/REJECT), score (0.0–1.0), reasons (string array, comma-joined inside JDM).

  2. Add the new top-level metadata block:

    "metadata": {
      "seed_sources": ["allegations", "case_household"],
      "name_similarity_algorithm": "trigram_jaccard"
    }
  3. Initial Georgia thresholds (record in description):

    • AUTO: name_exact_match == true && dob_exact_match == true

    • SUGGEST: name_exact_match == true || name_similarity_score >= 0.85

    • REJECT (catch-all)

  4. Extend cargo xtask validate ruleset validation to:

    • Accept the new metadata block on rulesets that declare it (additive — existing 5 rulesets without it remain valid).

    • Reject seed_sources containing strings outside the bounded set (allegations, case_household).

    • Reject name_similarity_algorithm values that aren’t in the registered algorithm list (trigram_jaccard in v1).

  5. Add a synthetic-input fixture to services/craig-rules/tests/ruleset_discovery.rs if needed — verify the test loads and compiles georgia-person-match.json.

  6. Add 3 ruleset-evaluation tests (AUTO verdict, SUGGEST verdict, REJECT verdict) with synthetic SignalSet inputs.

  7. Smoke-test via craig rules evaluate CLI against a deployed devstack (manual QA in MR description).

Acceptance criteria:

  • cargo nextest run -p craig-rules — ruleset_discovery test loads and compiles georgia-person-match.json. 3+ new tests for AUTO/SUGGEST/REJECT verdicts.

  • rulesets/georgia/georgia-person-match.json validates per the pre-push validation gate (cargo xtask validate).

  • xtask validator rejects rulesets that declare unknown seed_sources or name_similarity_algorithm (1 sad-path test in xtask).

  • The ruleset’s description field documents the initial thresholds and the policy reference (e.g., GA DFCS Policy XX or "TBD pending practitioner review").

Verification:

cargo nextest run -p craig-rules --profile integration
cargo nextest run -p xtask --lib
cargo xtask validate --skip-docker

Dependencies: Step 1. Independent of Steps 2, 4–8.

Step 4 — report_persons table + pg_trgm + store layer

Files: services/craig-cases/migrations/<TIMESTAMP>_report_persons.sql (new — stamp at MR time; verify against ls services/craig-cases/migrations/ | sort | tail -1), services/craig-cases/src/store/report_persons.rs (new), services/craig-cases/src/store/mod.rs, services/craig-cases/src/store/models.rs (add ReportPerson), services/craig-cases/src/store/persons.rs (add list_household_by_case_in_tx for Step 5/6’s case_household seed source).

  1. Migration creates the table + extension + indices per the Design section. Stamp the timestamp at MR creation time using today’s UTC date in YYYYMMDDHHMMSS form. Verify against the latest migration immediately before authoring; do not pre-stamp far-future dates. Pre-push reseed surfaces ordering collisions on the contributor’s machine.

  2. Model type ReportPerson in store/models.rs.

  3. Store helpers in store/report_persons.rs:

    • create_link(pool, report_id, person_id, role, jsonb_index, linked_by, ruleset_name, ruleset_version) → Result<ReportPerson>

    • create_link_in_tx(tx, …​) — same fields, transactional variant for the convert path

    • delete_link_with_provenance(pool, link_id) → Result<Option<ReportPerson>> — returns the row before deletion so the unlink event can carry full provenance

    • list_by_report(pool, report_id) → Result<Vec<ReportPerson>>

    • list_by_person(pool, person_id) → Result<Vec<ReportPerson>>

    • find_by_unique(pool, report_id, person_id, role) → Result<Option<ReportPerson>> — for 409 conflict detection

    • prefilter_candidates_by_name(pool, name_query, limit) → Result<Vec<PersonRecord>> — uses pg_trgm % operator with similarity ORDER BY

  4. New helper in store/persons.rs:

    • list_household_by_case_in_tx(tx, case_id) → Result<Vec<PersonRecord>> — needed by the case_household seed source in Step 5.

  5. Migration applies cleanly on a fresh DB and on the seed fixture.

Acceptance criteria:

  • cargo xtask dev restart — schema migration applies cleanly.

  • cargo nextest run -p craig-cases --lib — 8+ new unit tests against the store helpers (happy + sad: not-found, duplicate-link 409, cascade-delete on report removal, cross-cutting active filter on persons, household-by-case empty-case, household-by-case non-empty).

  • No regressions in existing 138 craig-cases tests.

Verification:

cargo xtask dev restart
cargo nextest run -p craig-cases --lib
psql $CRAIG_CASES__DATABASE_URL -c "SELECT 1 FROM pg_extension WHERE extname='pg_trgm'"
psql $CRAIG_CASES__DATABASE_URL -c "\d+ report_persons"

Dependencies: Step 1. Independent of Steps 2, 3, 5–8 except: Step 5 imports these store helpers.

Files: services/craig-cases/src/api/report_persons.rs (new), services/craig-cases/src/api/mod.rs (router), services/craig-cases/src/matching/mod.rs (new — orchestration helpers; path is matching/, not person_matching/ — service prefix disambiguates against placement’s matching/), services/craig-cases/src/matching/seed.rs (new), services/craig-cases/src/matching/similarity.rs (new — registry), services/craig-cases/src/matching/ruleset_metadata.rs (new — typed accessor for the new metadata fields), crates/craig-test-lib/src/clients/cases.rs (test client extensions), integration tests at services/craig-cases/tests/api/report_persons.rs (new), tests/k6/scenarios/person-suggestions-bench.js (new).

  1. New API module api/report_persons.rs exposing:

    • GET /v1/cases/reports/{id}/person-suggestions (caseworker_or_above)

    • POST /v1/cases/reports/{id}/person-links (caseworker_or_above)

    • DELETE /v1/cases/reports/{id}/person-links/{link_id} (caseworker_or_above)

  2. New module matching/:

    • mod ruleset_metadata — typed accessor: pub struct PersonMatchMetadata { seed_sources: Vec<SeedSource>, name_similarity_algorithm: String }. Loads from the ruleset’s metadata JSON block via the existing rules-engine client. Cached per (jurisdiction, version) tuple for the request lifetime.

    • mod seedSeedSource enum + collect_candidates(sources, ctx) → Vec<PersonRecord> per the Design section.

    • mod similarity — registry from algorithm string to &'static dyn NameSimilarity.

    • pub async fn rank_candidates(report, role, jsonb_index, db, rules_client, jurisdiction, seed_case_id) → Result<Vec<RankedCandidate>> — reads metadata, dispatches per seed_sources, unions with trgm pre-filter, runs L1 with the selected NameSimilarity, evaluates L2 via the existing RulesEngineClient against {jurisdiction}-person-match, returns sorted by score desc.

    • RankedCandidate { person_id, person_label, score, confidence, reasons, already_linked }.

  3. Suggestion endpoint loops over report.children[] and report.adults[], calling rank_candidates per entry. Already-linked entries surface a single-candidate response with already_linked: true. The suggestion endpoint accepts an optional ?seed_case_id={uuid} query parameter, mirroring the convert path.

  4. Link endpoint validates role ∈ {'child', 'adult'} (craig_reference::validate_enum if a LinkRole enum is added; otherwise inline check). Validates that jsonb_index is in range. Calls create_link and publishes case.report_person_linked (best-effort, non-blocking).

  5. Unlink endpoint reads the existing row, calls delete_link_with_provenance, publishes case.report_person_unlinked with the full-provenance payload from the Design section.

  6. Events module update: services/craig-cases/src/events.rs gains publish_report_person_linked + publish_report_person_unlinked (the latter takes the pre-deletion ReportPerson + the unlinker’s sub).

  7. k6 perf scenario tests/k6/scenarios/person-suggestions-bench.js:

    • Setup hook: create N test persons via concurrent POST loop (default N=1000 via env var, scalable to 100k for stress runs).

    • Main scenario: constant-VU load against GET /v1/cases/reports/{id}/person-suggestions with a known report ID seeded by a deterministic family.

    • Thresholds: http_req_duration{endpoint:person_suggestions}: p(95)<500, p(99)<1000 (initial budget; tune in MR).

    • Wired into existing cargo xtask perf infrastructure by adding a third axis: a --scenario <name> flag with a VALID_SCENARIOS allowlist alongside the existing --profile (load shape) and --service (service-targeted) flags. Profiles are load shapes (smoke/load/stress/soak); scenarios are domain-targeted benches — distinct concept, distinct flag. CLI invocation: cargo xtask perf --scenario person-suggestions-bench. --scenario is mutually exclusive with --profile and --service; reject combinations at flag-parse time. Estimated ~30 LOC of flag plumbing in xtask/src/cmd/perf.rs plus a unit test for argument parsing.

  8. Integration tests (15+):

    • Happy: suggest returns AUTO candidate with score + reasons (seed an exact match).

    • Happy: suggest returns SUGGEST candidates for fuzzy name match.

    • Happy: suggest returns empty candidates list when no person is similar.

    • Happy: suggest with ?seed_case_id={uuid} includes household members from that case in the candidate set.

    • Happy: suggest without ?seed_case_id against a case_household-declaring ruleset emits a warn log and returns trgm-only candidates (degraded mode).

    • Happy: link writes a row, returns 200, publishes the event.

    • Happy: unlink removes the row, returns 200, publishes the event with full provenance (originally_linked_by, ruleset_name, etc., populated from the deleted row).

    • Happy: linked entries show already_linked: true in subsequent suggest call.

    • Sad: suggest on unknown report 404.

    • Sad: link with bogus role 400.

    • Sad: link duplicate 409.

    • Sad: link with bogus ruleset_version accepted (we just stamp it; validation is intentionally weak — caseworker took the suggestion at face value).

    • Sad: link without caseworker role 403.

    • Sad: cascade — deleting a report cascades the report_persons row (ON DELETE CASCADE).

    • Sad: similarity-algorithm registry returns Err for an unknown name_similarity_algorithm — unit test against the select() registry function in services/craig-cases/src/matching/similarity.rs. (Pre-push validate already rejects malformed rulesets at the xtask layer per Step 3; this Step 5 unit test exercises the runtime safety net independently.)

Acceptance criteria:

  • cargo nextest run -p craig-cases --profile integration — 15+ new tests, no regressions.

  • OpenAPI spec updated; cargo xtask api-docs clean.

  • cargo xtask perf --scenario person-suggestions-bench runs end-to-end against devstack and reports thresholds.

  • No changes outside the new module + the router wiring + the events module + the xtask perf extension.

Verification:

cargo nextest run -p craig-cases --profile integration
cargo xtask api-docs
cargo xtask perf --scenario person-suggestions-bench

Dependencies: Steps 2, 3, 4. Blocks: Steps 6, 8.

Overridden by errata E-01. This step’s "single transaction wraps referral creation + auto-link" framing has been superseded by the sequential approach (referral tx commits, case.referral_created emits, then a separate best-effort auto-link tx for the row inserts). See the Errata section. The acceptance criteria and integration tests in this section still apply; only the transaction shape changes.

Files: services/craig-cases/src/api/reports.rs (modify convert_report; extend ConvertReportRequest with seed_case_id: Option<Uuid>), services/craig-cases/src/store/referrals.rs (add create_referral_from_report_in_tx if not already present), services/craig-cases/src/store/persons.rs (add list_by_referral_allegations_in_tx), services/craig-cases/src/matching/mod.rs (auto_link_report_persons helper + AutoLinkOutcome type), services/craig-cases/src/events.rs (publish_report_persons_auto_linked).

  1. Extend ConvertReportRequest:

    #[derive(Deserialize, utoipa::ToSchema)]
    pub struct ConvertReportRequest {
        pub priority: String,
        #[serde(default)]
        pub icwa_flag: bool,
        /// Optional case id whose household persons join the auto-link
        /// candidate seed set. Used by rulesets that declare `case_household`
        /// in their metadata.seed_sources. Omitted → degraded mode for those
        /// rulesets (zero contribution from the case_household source).
        #[serde(default)]
        pub seed_case_id: Option<Uuid>,
    }
  2. Refactor convert_report to wrap the referral creation + auto-link in a single transaction.

  3. auto_link_report_persons runs rank_candidates per child/adult, INSERTs rows for AUTO verdicts only, returns AutoLinkOutcome { linked, skipped_due_to_ambiguity }.

  4. Multi-AUTO ambiguity: drop the entry (no row written), increment skipped_due_to_ambiguity, log tracing::warn! per occurrence with report_id + jsonb_index + role.

  5. Publish case.report_persons_auto_linked once per convert with { report_id, count, skipped_due_to_ambiguity }.

  6. Publish case.report_person_linked once per AUTO link (preserves the per-link audit trail).

  7. Idempotency: convert is already idempotent at the referral level (find_by_intake_report_id). Auto-link path must also be idempotent — repeat calls to convert (which already short-circuit) don’t double-insert.

  8. Best-effort fallback: any error from L1/L2/L3 is caught, logs a warning, returns AutoLinkOutcome::default() with count=0; the referral creation transaction commits.

  9. Integration tests (12+):

    • Happy: convert with no exact-match person seed → 0 auto-links, skipped_due_to_ambiguity == 0.

    • Happy: convert with one exact-match child + one fuzzy adult → 1 auto-link, 0 for the fuzzy adult.

    • Happy: convert with seed_case_id supplied → household members join the candidate set (verify by seeding a household where one member is an exact match).

    • Happy: convert without seed_case_id against the Georgia ruleset (which declares case_household) → degraded mode, no error.

    • Happy: repeat convert → no duplicate rows (idempotent).

    • Happy: rolled-back transaction (force a downstream error after the auto-link insert) — rows do not persist.

    • Happy: ambiguity (force two candidates that both verdict AUTO via crafted fixtures) → row not written, skipped_due_to_ambiguity == 1 in the event payload.

    • Sad: rules engine unreachable during convert → convert succeeds, auto-link count = 0, warning logged.

    • Sad: ruleset returns malformed verdict → same fallback as above.

    • Sad: seed_case_id references a non-existent case → degraded mode (zero contribution from that source), no 404 from convert.

Acceptance criteria:

  • cargo nextest run -p craig-cases --profile integration — 12+ new tests.

  • CHANGELOG entry under == Unreleased.

  • Existing convert tests pass unchanged.

  • OpenAPI spec reflects the new seed_case_id field.

Verification:

cargo nextest run -p craig-cases --profile integration
cargo xtask validate --skip-docker
cargo xtask api-docs

Dependencies: Step 5. Blocks: Step 8 (the BFF chain handler benefits from auto-linked rows existing on converts).

Step 7 — ?person_id= filter on GET /v1/cases/reports

Files: services/craig-cases/src/api/reports.rs (extend ReportListQuery with person_id: Option<Uuid>), services/craig-cases/src/store/reports.rs (extend list_reports SQL), integration tests at services/craig-cases/tests/api/reports.rs.

  1. Mirror Phase 1’s pattern from MR !153 (referrals/investigations/cases).

  2. SQL extension uses the design section’s subquery (report_id IN (SELECT report_id FROM report_persons WHERE person_id = $X)).

  3. Index — idx_report_persons_person is already in place from Step 4. Verify EXPLAIN ANALYZE shows index usage on the seeded dataset (manual QA in MR description; deeper coverage lives in the k6 scenario shipped in Step 5).

  4. Integration tests (4+):

    • Happy: list_reports_filtered_by_person_id_returns_only_matching — exact symmetry to the Phase 1 referrals test.

    • Happy: returns empty when person has no linked reports.

    • Happy: pagination + filter compose correctly.

    • Happy: filter respects ON DELETE CASCADE — deleting a report removes its report_persons rows and the report disappears from the filtered list.

    • Sad: bogus UUID 400 (axum Path/Query rejection).

Acceptance criteria:

  • cargo nextest run -p craig-cases --profile integration — 4+ new tests.

  • No regressions.

  • OpenAPI updated.

Verification:

cargo nextest run -p craig-cases --profile integration
cargo xtask api-docs

Dependencies: Step 4. Independent of Steps 5–6 except: Step 8 depends on this.

Step 8 — craig-web BFF retire fallback + "Suggest matches" UX (with manifest contingency)

Files: services/craig-web/src/routes/cases/chain.rs (retire fallback), services/craig-web/src/routes/intake/reports.rs (add suggestion partial routes), services/craig-web/templates/intake/report_detail.html (add Suggest matches affordances per child/adult row), services/craig-web/templates/intake/_person_suggestions_partial.html (new htmx fragment), tests at services/craig-web/tests/…​ (or an E2E spec). Contingency: tools/craig-seed/src/manifest.rs iff the chain-view UI spec needs report_persons IDs from the manifest.

  1. chain.rs:

    • Add &person_id={id} to reports_path.

    • Set perf_note_visible = false (or remove the field + template branch entirely).

    • Update the comment block at line 7–17 to reflect Phase 2 has shipped — perf-note retired, person_id filter active.

  2. report_detail.html:

    • Per child/adult row, render either:

      • "Linked to <Person Name> [Unlink]" when a report_persons row exists (server-side join in the BFF before render), or

      • "Suggest matches" htmx button → hx-get="/cases/reports/{id}/person-suggestions/partial?role=child&index=0" → swap into a result panel beside the row.

    • Result panel renders top 5 candidates with name | DOB | reasons | [Confirm] | [Cancel]. Confirm htmx-POSTs to /cases/reports/{id}/person-links/partial with role/index/person_id and swaps the result panel into the "Linked to …​" view.

  3. BFF routes (under /cases/reports/…​ to keep symmetry with current intake-reports page):

    • GET /cases/reports/{id}/person-suggestions/partial?role=&index= — proxies to cases, renders fragment.

    • POST /cases/reports/{id}/person-links/partial — form-encoded, proxies, returns the linked-state fragment.

    • POST /cases/reports/{id}/person-links/{link_id}/unlink/partial — same.

  4. Manifest contingency: if the chain-view UI spec needs report_persons IDs from the seed manifest (e.g., to assert deterministic linking in E2E), extend tools/craig-seed/src/manifest.rs as part of this MR. If the spec relies on rendered text (person name, "Linked to" badge), skip the manifest change. Decision lives with the Step 8 MR author at draft time; do not pre-litigate during this plan.

  5. i18n: Fluent strings for "Suggest matches", "Linked to {0}", "Unlink", "No matches found", confidence labels, "Linked under <ruleset> v<version>".

  6. E2E (3 new specs in tests/e2e/specs/):

    • intake-person-link.spec.ts happy path: caseworker views report, clicks Suggest matches, sees a candidate, confirms, sees the linked state, navigates to person chain view, sees the report appear.

    • intake-person-link.spec.ts sad: clicking Suggest with no matches shows "No matches found".

    • chain-view-no-perf-banner.spec.ts: family chain view no longer shows the perf-note banner.

Acceptance criteria:

  • cargo xtask e2e — all existing + 3 new specs pass.

  • Existing chain-view spec updated if it was asserting the perf banner.

  • cargo nextest run -p craig-web — BFF route tests pass.

  • cargo xtask check-docs clean.

  • If the manifest was extended, tests/e2e/lib/seed.ts regenerates cleanly via craig-seed --manifest.

Verification:

cargo nextest run -p craig-web --profile integration
cargo xtask e2e
cargo xtask check-docs

Dependencies: Steps 5, 6, 7. Blocks: Step 9.

Step 9 — Plan completion audit + archive

Files: this plan (status table → all Complete), docs/modules/ROOT/pages/plans/archive.adoc (new row), docs/modules/ROOT/nav.adoc (Active → Archive), .claude/CLAUDE.md (Phase Status table updated as the LAST MR per git-workflow.md).

  1. Spawn a plan-completion-audit subagent per delivery-protocol.md. Audit Steps 1–8 against the actual codebase: all acceptance criteria met, all files listed touched, doc updates landed, test counts accurate, no stale references.

  2. Update the status table — all rows Complete with MR references.

  3. Move the plan from Active → Archive in nav.adoc.

  4. Add archive row under Architecture/Refactor.

  5. Close GitLab issues with closing comments per gitlab-workflow.md. Close parent epic.

  6. Update CHANGELOG with a wrap-up entry.

  7. Update .claude/docs/services.md craig-cases stats: endpoint count rises by 3 (suggestions + 2 link/unlink), table count rises by 1 (report_persons), event count rises by 3 (case.report_person_linked, case.report_person_unlinked, case.report_persons_auto_linked).

  8. Update .claude/docs/shared-crates.md with the new craig-matching crate’s public API (including the NameSimilarity trait + TrigramJaccard impl).

  9. Update .claude/docs/rulesets.md: domain count rises from 5 to 6 (person-match); document the metadata.seed_sources + metadata.name_similarity_algorithm extension as a generalizable pattern (signal for how #212/#213 should follow).

  10. Update .claude/docs/architecture.md if the three-layer matching architecture warrants a callout in the system overview.

Acceptance criteria:

  • cargo xtask check-docs clean.

  • Plan Completion Audit reports zero residual findings.

  • glab issue list --milestone "2026 Q3 — Feature Initiatives" --state opened shows zero report-person-linking issues open.

Verification:

cargo xtask check-docs
glab issue list --milestone "2026 Q3 — Feature Initiatives"

Dependencies: All prior steps.

Critical Files

File Purpose

crates/craig-matching/src/lib.rs

Step 2 — compute_signals + NameSimilarity trait + TrigramJaccard impl

rulesets/georgia/georgia-person-match.json

Step 3 — JDM ruleset + new metadata.seed_sources + metadata.name_similarity_algorithm

services/craig-cases/migrations/<TIMESTAMP>_report_persons.sql

Step 4 — schema + pg_trgm + name-trigram index (timestamp stamped at MR time)

services/craig-cases/src/matching/mod.rs

Step 5 + 6 — orchestration; reads ruleset metadata, dispatches seed sources, picks similarity algorithm

services/craig-cases/src/api/report_persons.rs

Step 5 — suggestions + link/unlink endpoints

services/craig-cases/src/api/reports.rs

Step 6, 7 — auto-link transaction + seed_case_id field; ?person_id= filter on list

tests/k6/scenarios/person-suggestions-bench.js

Step 5 — perf benchmark wired into cargo xtask perf

services/craig-web/src/routes/cases/chain.rs

Step 8 — retire fallback + perf-note banner

services/craig-web/templates/intake/report_detail.html

Step 8 — "Suggest matches" UX

Verification (whole plan)

  1. cargo nextest run --workspace --locked --profile integration — all tests pass; expect total to grow by ~50–60.

  2. cargo xtask e2e — all existing + 3 new specs pass.

  3. cargo xtask validate --skip-docker — fmt, clippy, build, ruleset validation (incl. new metadata-block checks), deny check all clean.

  4. cargo xtask perf --scenario person-suggestions-bench — k6 scenario passes its p95/p99 thresholds.

  5. cargo xtask check-docs — Tier 1 docs untouched.

  6. Manual QA: submit a report via the public form, screen it in, convert to referral with seed_case_id of an existing case, observe auto-linked rows in report_persons. Open the report in the caseworker UI, click "Suggest matches" on remaining unlinked entries, confirm one, observe the chain view at /cases/persons/{id}/chain — the report appears and the perf-note banner is gone. Unlink one row, observe case.report_person_unlinked in the audit_log with full provenance.

  7. Plan Completion Audit subagent reports zero residual findings.

Documentation Updates

  • .claude/docs/services.md — craig-cases endpoint count +3, table count +1, event count +3

  • .claude/docs/shared-crates.md — add craig-matching public API surface (incl. NameSimilarity trait + TrigramJaccard impl)

  • .claude/docs/architecture.md — note the three-layer matching architecture as a generalizable pattern; add craig-matching to the Component Hierarchy diagram

  • .claude/docs/rulesets.md — extend the "5 Ruleset Domains" / "10 Ruleset Files" sections to include the 6th domain (person-match); document the metadata.seed_sources + metadata.name_similarity_algorithm extension as the prior-art pattern for #212/#213

  • .claude/docs/local-dev.md — note the new pg_trgm extension dependency (no install required; Postgres contrib module ships with the devstack image)

  • .claude/CLAUDE.md — Phase Status table reference (last MR only, per git-workflow.md)

  • CHANGELOG.adoc — entry per MR under == Unreleased

  • docs/modules/ROOT/pages/architecture.adoc — Antora architecture page: callout for the three-layer matching pattern + ruleset-metadata extension

  • docs/modules/ROOT/pages/api/craig-cases.adoc — Antora API doc: 3 new endpoints + the new seed_case_id field on convert

  • docs/modules/ROOT/pages/data-model-cases.adoc — Antora data model: report_persons table

  • docs/modules/ROOT/pages/plans/archive.adoc — add report-person-linking row on completion (Step 9)

  • docs/modules/ROOT/nav.adoc — move plan Active → Archive on completion

Risks + Mitigations

Risk Mitigation

pg_trgm performance at scale. Trigram-index build + maintenance is well-understood but not free. On a 500k-person dataset, GIN updates may slow down persons write throughput.

Step 5 ships tests/k6/scenarios/person-suggestions-bench.js wired into cargo xtask perf. The setup hook spawns N persons (parameterized via env var), the measurement scenario hits the suggestions endpoint with constant-VU load, and thresholds enforce p95<500ms / p99<1000ms (initial budget; tune in MR). If write throughput regresses meaningfully, fall back to a REGCONFIG’d tsvector GIN or a separate persons_search materialized view with periodic refresh.

Person table cardinality affecting suggestion latency. As persons grows, even a LIMIT 25 pre-filter may need to scan many index entries before returning. The L2 ruleset evaluation is per-candidate, not per-set, so latency is candidates × ruleset eval cost.

Two mitigations: (a) cap candidates_per_entry at 25 hard, 5 default; (b) consider parallelizing the L2 evaluations via tokio::join! across candidates within a single entry. Defer (b) to Step 5 MR if the k6 scenario shows single-threaded eval is too slow.

Ruleset versioning when an existing report_persons row was written under a now-changed ruleset. ADR-019 says rows are frozen with (ruleset_name, ruleset_version) at write time. The risk is that workers see "linked" rows whose ruleset has since been retracted, and they don’t have a clear signal that the link’s logic is stale.

Make the freeze crisp: any worker re-querying suggestions for an entry that’s already linked sees already_linked: true regardless of the current ruleset’s verdict. To re-evaluate under a new ruleset, the worker explicitly unlinks (which fires the full-provenance unlink event) and re-runs Suggest, producing a fresh row with the new ruleset version. Document this in the report-detail UI’s tooltip ("Linked under <ruleset> v1.0").

UI fatigue from too many "unlinked" decorations. If a report has 6 children and 4 adults all unlinked, the report-detail page becomes a sea of yellow "Suggest matches" buttons.

Two mitigations: (a) batch the auto-link path so the convert flow handles the obvious AUTO matches before the worker ever sees the report — this is exactly the auto-link branch in Step 6 — leaving only the genuinely-ambiguous entries for manual confirm; (b) collapse the "Suggest matches" panel to a single "Find matches for all entries" button at the top of the report. Defer (b) to a follow-up if (a) doesn’t reduce visual load enough.

Cross-service auth on the suggestions endpoint. Suggestion calls from craig-web BFF to craig-cases use the caseworker’s bearer token, same as every other authenticated cases call.

No new auth surface — but if a future portal (constituent, foster-parent) needs to call suggestions, the authority story changes. Documented as an Open Question.

Trigram pre-filter false negatives. A worker has typed a very-non-canonical name into the report (e.g., "J. Doe") that doesn’t trgm-match "Jane Doe" above the 0.3 threshold. The matcher returns no candidates; the worker falls back to "Link existing person" deterministic search.

Add an empty-state message: "No automatic matches found. Use 'Link existing person' to search by exact name or other identifier." Pin in Step 8 acceptance criteria.

Convert-time auto-link transaction-failure cascade. If the auto-link insert fails inside the convert transaction, the whole transaction rolls back — the referral isn’t created. This is wrong: the matcher is best-effort, the referral creation is mandatory.

Step 6 implementation rule: catch any error from the auto-link path, log a warning, return AutoLinkOutcome::default(), and let the rest of the transaction proceed. Tests cover this in Step 6.

Ruleset declares an algorithm or seed source the orchestrator doesn’t know. A future ruleset author types name_similarity_algorithm: "jaro_winkler" before that impl ships, or adds a new seed_sources variant.

Step 3 extends cargo xtask validate to reject unknown values at pre-push time. Ruleset-discovery test in Step 3 includes a sad-path that tries to load a ruleset with an unknown algorithm and asserts the validator catches it. Service startup also re-validates and fails loudly if a deployed ruleset references an unknown algorithm.

Multi-AUTO ambiguity is silent in production. Logs alone get drowned; we need durable observability.

Dual-emit: tracing::warn! per occurrence (dev-loop signal) + skipped_due_to_ambiguity counter on case.report_persons_auto_linked (durable, queryable via audit_log). Tested in Step 6.

Open questions

  1. Auto-link threshold tuning per jurisdiction. Initial Georgia threshold is name_exact_match && dob_exact_match. Real-deployment data will probably push toward "exact name + DOB-within-30-days." Tuning lives in the ruleset; document the policy in the ruleset’s description.

  2. Multi-row-per-(report, person) under role evolution. A 17-year-old caregiver could legally appear once as a child and once as an adult on the same report. The UNIQUE (report_id, person_id, role) constraint allows that. Confirm with practitioners that this matches reality.

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

  4. Texas texas-person-match.json v1 thresholds + metadata choices. Defer to a follow-up MR after Georgia ships. Different jurisdictions may want very different signals (Texas has 4 LOC tiers vs. Georgia’s 3, etc. per .claude/docs/rulesets.md). Texas may also pick a different name_similarity_algorithm once Jaro-Winkler ships.

  5. Future seed sources. Examples: prior-report co-occurrence, partner-vendor identity assertion, partner-supplied SSN-blind-index hits. Each new source is a new enum variant + a new collector function in matching/seed.rs.

  6. Folding #212 / #213 into this plan. Decision: separate. Different domain (screening allowlists vs. person-matching ruleset), different consumers (caseworker UI vs. system-side auto-link), different timeline. The pattern this plan establishes (signal compute → JDM decision → orchestration with ruleset metadata) generalizes; the #212/#213 plans, when written, should reference this one as prior art for the metadata-extension technique. Argued.

  7. Rejected suggestions persistence. Initial design persists confirmations only. Worth tracking how often workers re-suggest an entry only to reject the same candidate again — if it becomes friction, add a rejected_suggestions table or a column on report_persons with status = 'rejected'. Defer.

GitLab issues to file (Step 1 creates these)

  1. feat(craig-matching): NameSimilarity trait + TrigramJaccard + signal compute crate — Step 2

  2. feat(rulesets): georgia-person-match JDM ruleset with metadata extensions — Step 3

  3. feat(craig-cases): report_persons table + pg_trgm extension + store layer — Step 4

  4. feat(craig-cases): orchestration — suggestions + link/unlink + ruleset-driven seed dispatch + k6 perf scenario — Step 5

  5. feat(craig-cases): auto-link branch in convert_report (seed_case_id + ambiguity counter) — Step 6

  6. feat(craig-cases): ?person_id= filter on GET /v1/cases/reports — Step 7

  7. feat(craig-web): retire chain-view fallback + Suggest matches UX — Step 8

  8. chore(plans): report-person-linking completion audit + archive — Step 9

Errata

E-01 — Step 6 transaction shape: sequential, not single-tx (2026-04-27)

Original spec (Step 6, line 671): "Refactor convert_report to wrap the referral creation + auto-link in a single transaction."

Accepted version: Sequential — referral tx commits, case.referral_created event emits, then a separate best-effort short tx for the auto-link row inserts.

Why the deviation:

  1. HTTP RPCs inside an open postgres tx is an anti-pattern. The auto-link path issues N rules-engine calls per JSONB entry (one per pre-filtered candidate, capped at 25). For a report with 3 children + 2 adults, that’s potentially 100+ HTTP roundtrips with the tx open and row locks held on referrals. If craig-rules is slow or paged, the tx hangs and starves other writers.

  2. Best-effort fallback semantics fight single-tx framing. The plan said any L1/L2/L3 error returns AutoLinkOutcome::default() (count=0). But if entries 1–2 already INSERTed rows in the tx and entry 3 errors, the contract is unclear (keep them? roll back the convert?). Single-tx pushes you toward "all or nothing" thinking that contradicts the "auto-link is allowed to fail" semantic.

  3. Event emission gets serialized. With single-tx, case.referral_created (which downstream consumers already subscribe to) can’t fire until auto-link finishes — so existing audit/analytics gain auto-link’s latency for no benefit.

Implementation contract (replaces Step 6 step 2 "Refactor convert_report…​"):

  1. Open referral tx (or use the existing create_referral_from_report pool variant — sequential semantics make a dedicated tx unnecessary for the referral itself).

  2. Commit referral creation; emit case.report_converted + case.referral_created.

  3. Best-effort auto_link_report_persons runs against the now-committed referral. Each AUTO row insert uses its own short tx (or single-statement INSERT). On any error, log warning, return AutoLinkOutcome::default().

  4. Emit case.report_persons_auto_linked (count + skipped_due_to_ambiguity) + case.report_person_linked per inserted row, after auto-link completes.

Tests affected: The "force a downstream error after the auto-link insert — rows do not persist" test (Step 6, line 684) is restated as "if auto-link insert errors mid-batch, no auto-link rows persist for that batch; referral still exists." Cleaner contract, same intent.

store/persons.rs list_by_referral_allegations_in_tx: Not needed under sequential — auto-link reads via the pool against the committed referral. The _in_tx suffix becomes moot.

Follow-up (2026-07-07, #799) — per-link audit now atomic with its row insert. The step above staged each row insert on the pool (auto-commit) and emitted the case.report_person_linked audit event later in a separate best-effort Tx 2, so a failure of that Tx left committed links un-audited. Each auto-link row insert now runs in its own short transaction that ALSO stages its case.report_person_linked event (matching::apply_auto_link_verdictinsert_audited_link): the link and its provenance audit commit together or roll back together. If the audit event cannot be staged the row is not created (logged, then skipped — auto-link stays best-effort at the pass level, and convert still returns 200). Tx 2 now carries only the case.report_persons_auto_linked rollup. The "single tx wraps referral + auto-link" framing is still overridden (the referral commits separately); only the per-link audit gained atomicity with its row.

Edit this page · latest