Plan: Report-Person Linking with Pluggable Jurisdictional Matching
On this page
- Status
- Context
- Scope
- Design
- Three-layer architecture summary
craig-matchingcrate API{jurisdiction}-person-matchruleset shape- Schema
- Orchestration: candidate pre-filter
- Seed-source dispatch
- Similarity-algorithm registry
- Suggestion endpoint contract
- Link/unlink endpoint contract
- Auto-link branch in
convert_report ?person_id=filter onGET /v1/cases/reports- craig-web BFF retire of fallback
- Audit + events
- Steps
- Step 1 — Plan + ADR-019 accept + GitLab issue tree
- Step 2 —
craig-matchingcrate - Step 3 —
georgia-person-matchJDM ruleset (with metadata) - Step 4 —
report_personstable + pg_trgm + store layer - Step 5 — Orchestration: suggestions + link/unlink + ruleset-driven seed dispatch + perf scenario
- Step 6 — Auto-link branch in
convert_report(withseed_case_id+ ambiguity payload) - Step 7 —
?person_id=filter onGET /v1/cases/reports - Step 8 — craig-web BFF retire fallback + "Suggest matches" UX (with manifest contingency)
- Step 9 — Plan completion audit + archive
- Critical Files
- Verification (whole plan)
- Documentation Updates
- Risks + Mitigations
- Open questions
- GitLab issues to file (Step 1 creates these)
- Errata
Status
| Step | Description | Status |
|---|---|---|
1 |
Plan + ADR-019 accept + GitLab issue tree + nav.adoc Active entry |
Done (2026-04-26) — MR !154 |
2 |
|
Done (2026-04-26) — MR !155, closes #217 |
3 |
|
Done (2026-04-26) — MR !156, closes #218 |
4 |
|
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 |
Done (2026-04-27) — MR !159, closes #221 |
7 |
|
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-matching—NameSimilaritytrait +TrigramJaccardimpl (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). Bothmetadata.seed_sourcesandmetadata.name_similarity_algorithmare required fields. -
New table
cases.report_persons+pg_trgmextension + name-trigram index onpersons -
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 toGET /v1/cases/reports -
Optional
seed_case_id: Option<Uuid>field added toConvertReportRequest -
Auto-link branch inside
convert_report(transactional, best-effort) -
k6 perf scenario
tests/k6/scenarios/person-suggestions-bench.jswired intocargo 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.rsextension 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_reportwhich 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 |
|
|
L2 — Decision |
|
JDM emits |
L3 — Orchestration |
|
|
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 whenname_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."
Link/unlink endpoint contract
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.
Auto-link branch in convert_report
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):
-
reports_pathbuilds an unfiltered query with noperson_id. -
perf_note_visible = reports.total > CHAIN_PAGE_SIZE as i64(line 249).
After Step 8:
-
reports_pathincludes&person_id={id}. -
perf_note_visibleretired (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 theoriginally_*and ruleset fields; the wildcard audit subscriber writes this intoaudit_logfor 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).
-
Land ADR-019 with Status: Accepted (this commit).
-
Add this plan to
nav.adocunder** Active. Add ADR-019 to the ADR list. -
Create parent GitLab epic for report-person-linking, milestone
2026 Q3 — Feature Initiatives. -
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_idAPI field
-
-
Update CLAUDE.md Phase Status table reference (last MR per
git-workflow.mdmulti-MR rule — defer to Step 9).
Acceptance criteria:
-
cargo xtask check-docspasses — 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.
-
Create the crate. SPDX header on every file. Workspace member.
-
Implement
NameSimilaritytrait +TrigramJaccardimpl backed bystrsim(Sørensen-Dice over bigram sets is the canonical "trigram-Jaccard-style" name match; pickstrsim::sorensen_dicefor stability or wrapstrsimto compute Jaccard explicitly — record decision in MR description).algorithm_name()returns"trigram_jaccard". -
Implement
compute_signals(entry, person, sim) → SignalSet:-
name_exact_match— case-insensitive whole-name compare after trim. -
name_similarity_score—sim.score(full_name(entry), full_name(person)); bounded 0.0..=1.0. -
dob_exact_match/dob_within_30_days/dob_year_match—chrono::NaiveDatemath; both sides must beSome(…). -
gender_match— case-insensitive equality after trim;falsewhen either side isNone. -
phone_match— strip non-digits and compare last 10 digits;falsewhen either side isNone.
-
-
Tests (12+, 100% happy + sad coverage per testing.md):
-
Happy:
name_exact_matchtrue 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_matchtrue on identical dates;dob_within_30_daystrue on dates 5/29 days apart and false on 31;dob_year_matchtrue on same year different month. -
Happy:
phone_matchstrips 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_matchisfalsewhen either side isNone. -
Sad:
phone_matchisfalsewhen either side isNoneor stripped to <10 digits. -
Sad:
compute_signalswith bothdobNonereturnsdob_exact_match=false && dob_within_30_days=false && dob_year_match=false(no false positives). -
Property test (proptest, optional):
compute_signalsis total — never panics on any combination of `Option`s.
-
-
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-matchingclean. -
cargo clippy -p craig-matching — -D warningsclean. -
No I/O in the crate (no
tokio, nosqlx, noreqwestinCargo.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).
-
Author the JDM file per the structure above. Single
decisionTableNode,hitPolicy: "first". Inputs map 1:1 toSignalSetfields. Outputs areconfidence(AUTO/SUGGEST/REJECT),score(0.0–1.0),reasons(string array, comma-joined inside JDM). -
Add the new top-level
metadatablock:"metadata": { "seed_sources": ["allegations", "case_household"], "name_similarity_algorithm": "trigram_jaccard" } -
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)
-
-
Extend
cargo xtask validateruleset validation to:-
Accept the new
metadatablock on rulesets that declare it (additive — existing 5 rulesets without it remain valid). -
Reject
seed_sourcescontaining strings outside the bounded set (allegations,case_household). -
Reject
name_similarity_algorithmvalues that aren’t in the registered algorithm list (trigram_jaccardin v1).
-
-
Add a synthetic-input fixture to
services/craig-rules/tests/ruleset_discovery.rsif needed — verify the test loads and compilesgeorgia-person-match.json. -
Add 3 ruleset-evaluation tests (AUTO verdict, SUGGEST verdict, REJECT verdict) with synthetic SignalSet inputs.
-
Smoke-test via
craig rules evaluateCLI against a deployed devstack (manual QA in MR description).
Acceptance criteria:
-
cargo nextest run -p craig-rules— ruleset_discovery test loads and compilesgeorgia-person-match.json. 3+ new tests for AUTO/SUGGEST/REJECT verdicts. -
rulesets/georgia/georgia-person-match.jsonvalidates per the pre-push validation gate (cargo xtask validate). -
xtask validator rejects rulesets that declare unknown
seed_sourcesorname_similarity_algorithm(1 sad-path test in xtask). -
The ruleset’s
descriptionfield 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).
-
Migration creates the table + extension + indices per the Design section. Stamp the timestamp at MR creation time using today’s UTC date in
YYYYMMDDHHMMSSform. 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. -
Model type
ReportPersoninstore/models.rs. -
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>>— usespg_trgm%operator with similarity ORDER BY
-
-
New helper in
store/persons.rs:-
list_household_by_case_in_tx(tx, case_id) → Result<Vec<PersonRecord>>— needed by thecase_householdseed source in Step 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.
Step 5 — Orchestration: suggestions + link/unlink + ruleset-driven seed dispatch + perf scenario
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).
-
New API module
api/report_persons.rsexposing:-
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)
-
-
New module
matching/:-
mod ruleset_metadata— typed accessor:pub struct PersonMatchMetadata { seed_sources: Vec<SeedSource>, name_similarity_algorithm: String }. Loads from the ruleset’smetadataJSON block via the existing rules-engine client. Cached per (jurisdiction, version) tuple for the request lifetime. -
mod seed—SeedSourceenum +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 perseed_sources, unions with trgm pre-filter, runs L1 with the selectedNameSimilarity, evaluates L2 via the existingRulesEngineClientagainst{jurisdiction}-person-match, returns sorted by score desc. -
RankedCandidate { person_id, person_label, score, confidence, reasons, already_linked }.
-
-
Suggestion endpoint loops over
report.children[]andreport.adults[], callingrank_candidatesper entry. Already-linked entries surface a single-candidate response withalready_linked: true. The suggestion endpoint accepts an optional?seed_case_id={uuid}query parameter, mirroring the convert path. -
Link endpoint validates
role ∈ {'child', 'adult'}(craig_reference::validate_enumif aLinkRoleenum is added; otherwise inline check). Validates thatjsonb_indexis in range. Callscreate_linkand publishescase.report_person_linked(best-effort, non-blocking). -
Unlink endpoint reads the existing row, calls
delete_link_with_provenance, publishescase.report_person_unlinkedwith the full-provenance payload from the Design section. -
Events module update:
services/craig-cases/src/events.rsgainspublish_report_person_linked+publish_report_person_unlinked(the latter takes the pre-deletionReportPerson+ the unlinker’s sub). -
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-suggestionswith 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 perfinfrastructure by adding a third axis: a--scenario <name>flag with aVALID_SCENARIOSallowlist 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.--scenariois mutually exclusive with--profileand--service; reject combinations at flag-parse time. Estimated ~30 LOC of flag plumbing inxtask/src/cmd/perf.rsplus a unit test for argument parsing.
-
-
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_idagainst acase_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: truein subsequent suggest call. -
Sad: suggest on unknown report 404.
-
Sad: link with bogus role 400.
-
Sad: link duplicate 409.
-
Sad: link with bogus
ruleset_versionaccepted (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
Errfor an unknownname_similarity_algorithm— unit test against theselect()registry function inservices/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-docsclean. -
cargo xtask perf --scenario person-suggestions-benchruns 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.
Step 6 — Auto-link branch in convert_report (with seed_case_id + ambiguity payload)
|
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, |
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).
-
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>, } -
Refactor
convert_reportto wrap the referral creation + auto-link in a single transaction. -
auto_link_report_personsrunsrank_candidatesper child/adult, INSERTs rows for AUTO verdicts only, returnsAutoLinkOutcome { linked, skipped_due_to_ambiguity }. -
Multi-AUTO ambiguity: drop the entry (no row written), increment
skipped_due_to_ambiguity, logtracing::warn!per occurrence withreport_id+jsonb_index+role. -
Publish
case.report_persons_auto_linkedonce per convert with{ report_id, count, skipped_due_to_ambiguity }. -
Publish
case.report_person_linkedonce per AUTO link (preserves the per-link audit trail). -
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. -
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. -
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_idsupplied → household members join the candidate set (verify by seeding a household where one member is an exact match). -
Happy: convert without
seed_case_idagainst the Georgia ruleset (which declarescase_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 == 1in 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_idreferences 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_idfield.
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.
-
Mirror Phase 1’s pattern from MR !153 (referrals/investigations/cases).
-
SQL extension uses the design section’s subquery (
report_id IN (SELECT report_id FROM report_persons WHERE person_id = $X)). -
Index —
idx_report_persons_personis 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). -
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 itsreport_personsrows 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.
-
chain.rs:
-
Add
&person_id={id}toreports_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.
-
-
report_detail.html:
-
Per child/adult row, render either:
-
"Linked to <Person Name> [Unlink]" when a
report_personsrow 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/partialwith role/index/person_id and swaps the result panel into the "Linked to …" view.
-
-
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.
-
-
Manifest contingency: if the chain-view UI spec needs
report_personsIDs from the seed manifest (e.g., to assert deterministic linking in E2E), extendtools/craig-seed/src/manifest.rsas 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. -
i18n: Fluent strings for "Suggest matches", "Linked to {0}", "Unlink", "No matches found", confidence labels, "Linked under <ruleset> v<version>".
-
E2E (3 new specs in
tests/e2e/specs/):-
intake-person-link.spec.tshappy 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.tssad: 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-docsclean. -
If the manifest was extended,
tests/e2e/lib/seed.tsregenerates cleanly viacraig-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).
-
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. -
Update the status table — all rows Complete with MR references.
-
Move the plan from Active → Archive in
nav.adoc. -
Add archive row under Architecture/Refactor.
-
Close GitLab issues with closing comments per
gitlab-workflow.md. Close parent epic. -
Update CHANGELOG with a wrap-up entry.
-
Update
.claude/docs/services.mdcraig-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). -
Update
.claude/docs/shared-crates.mdwith the newcraig-matchingcrate’s public API (including theNameSimilaritytrait +TrigramJaccardimpl). -
Update
.claude/docs/rulesets.md: domain count rises from 5 to 6 (person-match); document themetadata.seed_sources+metadata.name_similarity_algorithmextension as a generalizable pattern (signal for how #212/#213 should follow). -
Update
.claude/docs/architecture.mdif the three-layer matching architecture warrants a callout in the system overview.
Acceptance criteria:
-
cargo xtask check-docsclean. -
Plan Completion Audit reports zero residual findings.
-
glab issue list --milestone "2026 Q3 — Feature Initiatives" --state openedshows 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 |
|---|---|
|
Step 2 — |
|
Step 3 — JDM ruleset + new |
|
Step 4 — schema + pg_trgm + name-trigram index (timestamp stamped at MR time) |
|
Step 5 + 6 — orchestration; reads ruleset metadata, dispatches seed sources, picks similarity algorithm |
|
Step 5 — suggestions + link/unlink endpoints |
|
Step 6, 7 — auto-link transaction + |
|
Step 5 — perf benchmark wired into |
|
Step 8 — retire fallback + perf-note banner |
|
Step 8 — "Suggest matches" UX |
Verification (whole plan)
-
cargo nextest run --workspace --locked --profile integration— all tests pass; expect total to grow by ~50–60. -
cargo xtask e2e— all existing + 3 new specs pass. -
cargo xtask validate --skip-docker— fmt, clippy, build, ruleset validation (incl. new metadata-block checks), deny check all clean. -
cargo xtask perf --scenario person-suggestions-bench— k6 scenario passes its p95/p99 thresholds. -
cargo xtask check-docs— Tier 1 docs untouched. -
Manual QA: submit a report via the public form, screen it in, convert to referral with
seed_case_idof an existing case, observe auto-linked rows inreport_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, observecase.report_person_unlinkedin the audit_log with full provenance. -
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— addcraig-matchingpublic API surface (incl.NameSimilaritytrait +TrigramJaccardimpl) -
.claude/docs/architecture.md— note the three-layer matching architecture as a generalizable pattern; addcraig-matchingto 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 themetadata.seed_sources+metadata.name_similarity_algorithmextension as the prior-art pattern for #212/#213 -
.claude/docs/local-dev.md— note the newpg_trgmextension dependency (no install required; Postgres contrib module ships with the devstack image) -
.claude/CLAUDE.md— Phase Status table reference (last MR only, pergit-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 newseed_case_idfield on convert -
docs/modules/ROOT/pages/data-model-cases.adoc— Antora data model:report_personstable -
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 |
|---|---|
|
Step 5 ships |
Person table cardinality affecting suggestion latency. As |
Two mitigations: (a) cap |
Ruleset versioning when an existing |
Make the freeze crisp: any worker re-querying suggestions for an entry that’s already linked sees |
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 |
Ruleset declares an algorithm or seed source the orchestrator doesn’t know. A future ruleset author types |
Step 3 extends |
Multi-AUTO ambiguity is silent in production. Logs alone get drowned; we need durable observability. |
Dual-emit: |
Open questions
-
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’sdescription. -
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. -
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. -
Texas
texas-person-match.jsonv1 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 differentname_similarity_algorithmonce Jaro-Winkler ships. -
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. -
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.
-
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_suggestionstable or a column onreport_personswithstatus = 'rejected'. Defer.
GitLab issues to file (Step 1 creates these)
-
feat(craig-matching): NameSimilarity trait + TrigramJaccard + signal compute crate— Step 2 -
feat(rulesets): georgia-person-match JDM ruleset with metadata extensions— Step 3 -
feat(craig-cases): report_persons table + pg_trgm extension + store layer— Step 4 -
feat(craig-cases): orchestration — suggestions + link/unlink + ruleset-driven seed dispatch + k6 perf scenario— Step 5 -
feat(craig-cases): auto-link branch in convert_report (seed_case_id + ambiguity counter)— Step 6 -
feat(craig-cases): ?person_id= filter on GET /v1/cases/reports— Step 7 -
feat(craig-web): retire chain-view fallback + Suggest matches UX— Step 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:
-
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. -
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. -
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…"):
-
Open referral tx (or use the existing
create_referral_from_reportpool variant — sequential semantics make a dedicated tx unnecessary for the referral itself). -
Commit referral creation; emit
case.report_converted+case.referral_created. -
Best-effort
auto_link_report_personsruns against the now-committed referral. Each AUTO row insert uses its own short tx (or single-statement INSERT). On any error, log warning, returnAutoLinkOutcome::default(). -
Emit
case.report_persons_auto_linked(count +skipped_due_to_ambiguity) +case.report_person_linkedper 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_verdict → insert_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.