Skip to main content

Overview

The Applicant Identity Layer provides a shared infrastructure for tracking people who interact with the Unify platform across multiple applications. When someone applies for housing assistance through Terra and later uses Pathfinder to screen for other benefits, they’re recognized as the same person.
The identity layer is implemented in the @unify/identity package and can be used by any app in the monorepo.

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                    UNIFIED IDENTITY LAYER                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  WorkOS (Auth) ──▶ applicants ──▶ applicant_profiles           │
│                         │              │                        │
│                         │              ▼                        │
│                         │        applicant_pii                  │
│                         │              │                        │
│                         ▼              ▼                        │
│                  profile_contributions                          │
│                         │                                       │
│         ┌───────────────┼───────────────┐                      │
│         ▼               ▼               ▼                      │
│      Terra          Pathfinder       Future Apps               │
│   (submissions)    (screeners)                                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Database Tables

applicants

Core identity table linking authentication to applicant data.
ColumnTypeDescription
idUUIDPrimary key
workos_idTEXTWorkOS user ID (null for anonymous)
session_idTEXTSession tracking for anonymous users
emailTEXTPrimary email address
display_nameTEXTFull name for display
profile_versionINTEGERIncrements on profile changes
last_active_atTIMESTAMPTZLast interaction timestamp

applicant_profiles

Non-PII data that’s fully queryable for eligibility and reporting.
ColumnTypeDescription
household_sizeINTEGERNumber of people in household
children_under_18INTEGERCount of minor children
monthly_income_centsINTEGERTotal monthly income in cents
fpl_percentageINTEGERFederal Poverty Level percentage
housing_statusTEXT’stable’, ‘behind_on_rent’, ‘homeless’, etc.
food_securityTEXT’secure’, ‘low_security’, ‘urgent’
needs_childcareBOOLEANChildcare assistance needed
needs_transportationBOOLEANTransportation assistance needed
has_crisis_flagsBOOLEANAny urgent needs flagged

applicant_pii

Personal information with cleartext queryable fields and encrypted secrets.
ColumnQueryableDescription
first_name, last_nameYesName for search and matching
date_of_birthYesDOB for age calculations and verification
ssn_last_fourYesLast 4 digits for duplicate detection
ssn_encryptedNoFull SSN, AES-256 encrypted
address_*YesFull address (street, city, state, zip, county)
email, phoneYesContact information

applicant_bank_accounts

Bank information for disbursements.
ColumnQueryableDescription
bank_nameYesInstitution name
routing_numberYesABA routing number (public info)
account_last_fourYesLast 4 digits for display
account_number_encryptedNoFull account, encrypted
is_primaryYesPrimary account flag
is_verifiedYesVerification status

profile_contributions

Tracks data provenance - where each piece of profile data came from.
ColumnDescription
source_app’terra’, ‘pathfinder’, ‘admin’, ‘api’
source_type’form_submission’, ‘screener’, ‘manual’, ‘import’
source_idReference to original record
field_pathsArray of fields that were contributed
is_verifiedWhether data has been verified
verified_by’admin’, ‘system_ocr’, ‘partner_api’

Usage

Finding or Creating an Applicant

The find_or_create_applicant function handles identity resolution:
import { supabaseAdmin } from "@/lib/supabase";

// From a form submission
const { data: applicantId } = await supabaseAdmin.rpc(
  "find_or_create_applicant",
  {
    p_email: "maria@example.com",
    p_workos_id: session?.user?.id || null,
    p_session_id: null,
    p_first_name: "Maria",
    p_last_name: "Garcia",
  }
);
The function:
  1. Looks for existing applicant by workos_id (authenticated user)
  2. Falls back to matching by email
  3. Falls back to matching by session_id (anonymous)
  4. Creates new applicant if no match found
  5. Automatically creates applicant_profiles and applicant_pii records

Using the Identity Package

import {
  findOrCreateApplicant,
  getApplicantWithDetails,
  updateApplicantProfile,
  findDuplicates,
} from "@unify/identity";

// Get full applicant details
const result = await getApplicantWithDetails(supabase, applicantId);
if (result.success) {
  const { applicant, profile, pii, bank_accounts, contributions } = result.data;
}

// Update profile
await updateApplicantProfile(supabase, applicantId, {
  household_size: 4,
  fpl_percentage: 85,
  housing_status: "behind_on_rent",
  needs_childcare: true,
});

// Check for duplicates
const duplicates = await findDuplicates(supabase, applicantId);
// Returns matches based on SSN4+DOB, name+DOB, email, phone, address

Listing Applicants

import { listApplicants } from "@unify/identity";

const result = await listApplicants(supabase, {
  search: "garcia",
  housing_status: "behind_on_rent",
  has_crisis: true,
  fpl_max: 150,
  state: "WA",
  limit: 50,
  offset: 0,
});

Duplicate Detection

The find_potential_duplicates function identifies possible duplicate records:
SELECT * FROM find_potential_duplicates('applicant-uuid-here');
Returns matches with confidence scores:
Match TypeConfidenceCriteria
ssn4_dob_exact95%Same SSN last 4 + date of birth
name_dob_exact90%Same first name + last name + DOB
email_exact80%Same email address
phone_exact70%Same phone number
address_exact50%Same street address + zip

Data Flow

Form Submission (Terra)

When a form is submitted:
  1. Extract email from session or form data
  2. Call find_or_create_applicant() to get/create applicant
  3. Insert submission with applicant_id linked
  4. Record contribution in profile_contributions
// In submitFormResponse()
const { data: applicantId } = await supabaseAdmin.rpc(
  "find_or_create_applicant",
  { p_email: email, p_workos_id: userId, ... }
);

await supabaseAdmin.from("submissions").insert({
  form_id: formId,
  applicant_id: applicantId,  // Linked!
  data: formData,
});

await supabaseAdmin.from("profile_contributions").insert({
  applicant_id: applicantId,
  source_app: "terra",
  source_type: "form_submission",
  field_paths: Object.keys(formData),
});

Benefits Screener (Pathfinder)

When a screener is completed:
  1. Same identity resolution flow
  2. Update applicant_profiles with household/income data
  3. Record contribution with source_app: "pathfinder"

Security Considerations

What’s Encrypted

Only truly sensitive secrets are encrypted:
  • Full SSN (ssn_encrypted)
  • Full ITIN (itin_encrypted)
  • Full bank account numbers (account_number_encrypted)

What’s Queryable (Cleartext)

For operational needs (fraud detection, reporting, duplicate matching):
  • Names, date of birth
  • SSN last 4 digits
  • Full address
  • Bank name, routing number, account last 4
  • Income amounts, household composition

RLS Policies

All applicant tables have Row Level Security enabled:
  • Service role has full access (for server-side operations)
  • No direct client access (all operations through server actions)

Common Queries

Find applicants in crisis

SELECT a.*, p.*, pii.*
FROM applicants a
JOIN applicant_profiles p ON p.applicant_id = a.id
JOIN applicant_pii pii ON pii.applicant_id = a.id
WHERE p.has_crisis_flags = TRUE
ORDER BY a.last_active_at DESC;

Detect potential fraud (same bank account)

SELECT
  routing_number,
  account_last_four,
  COUNT(*) as applicant_count,
  ARRAY_AGG(applicant_id) as applicants
FROM applicant_bank_accounts
GROUP BY routing_number, account_last_four
HAVING COUNT(*) > 1;

Cross-program participation

SELECT
  a.display_name,
  a.email,
  COUNT(DISTINCT pc.source_app) as apps_used,
  ARRAY_AGG(DISTINCT pc.source_app) as apps
FROM applicants a
JOIN profile_contributions pc ON pc.applicant_id = a.id
GROUP BY a.id
HAVING COUNT(DISTINCT pc.source_app) > 1;

Migration

To add the identity layer to an existing deployment, run migration 022_applicant_identity.sql in your Supabase SQL editor.
Existing submissions will have applicant_id = NULL. New submissions will automatically create and link applicant records.
To backfill existing submissions:
-- Create applicants from existing submissions with user_id
INSERT INTO applicants (workos_id, email)
SELECT DISTINCT
  s.user_id,
  up.email
FROM submissions s
JOIN user_profiles up ON up.user_id = s.user_id
WHERE s.user_id IS NOT NULL
  AND s.applicant_id IS NULL
ON CONFLICT (workos_id) DO NOTHING;

-- Link submissions to applicants
UPDATE submissions s
SET applicant_id = a.id
FROM applicants a
WHERE s.user_id = a.workos_id
  AND s.applicant_id IS NULL;