Recruitment Pipeline — Feature Design (v2, post-adversarial-review)
App: NPM‑Helper‑App (Nightingale) · DB: Supabase NPM-Helper-App (dksjqoknmwpxfqkiygkb)
Date: 2026‑06‑17 · Status: Design for review — migration 0054_recruitment_pipeline.sql drafted (parses; not applied), interactive mockup + research brief attached · Proposed ADR‑0042
This is the hardened version after a five-front adversarial review (data model, UX/accessibility, compliance, recruiting domain, architecture). Every Critical finding is folded in;
ADVERSARIAL-REVIEW-AND-CHANGELOG.mdrecords each finding and its disposition. The most important corrections: a person/application split, a privacy + compliance spine, DB-enforced gates, three cadences instead of one, role-aware scoring, and an honest accounting of the app code that is not "free reuse."
v3 — second adversarial review (grounded against the live DB + real
src/). 44 verified findings; see the "Second adversarial review" section of the changelog. Corrections that supersede claims below: (1) the PII-masking audit companion now ships inside 0054 (audit_row_candidate()), not deferred; (2) thefollow_up_*"engine" is schema-only — runner, renderer, enroller,/api/cron/follow-ups, draft/approveAndSend, and the referral day-90 transition are net-new (§4/§5/§7 overstate reuse); (3) AdminNav/⌘K registration and the retention purge are to-be-built, not present; (4) migration fixes landed in 0054 (null-requisition dedup, latest-pass compliance gate, sibling-app withdrawal, consent gate); (5) the branch is behind live by the facility-pipeline migrations — rebase + regen types before any typed phase. Pure libs + Zod schemas are built and CI-green.
1. What this is
A supply-side applicant-tracking system for recruiting Philippine-based, PRC-licensed nurses (broad remote-clinical: RN/LPN, MDS / PDPM‑RAC / chart review / care coordination / clinical documentation) to work remotely as outsourced contractors for NPM's US SNF clients. It sources, screens, scores, nurtures, and hires candidates, then hands each hire into the existing contractor onboarding. It is the supply-side mirror of the Facility Pipeline (demand side), and it reuses, not rebuilds: the crm_leads Kanban shape, the follow_up_* cadence engine, outbound_emails, the AI orchestrator, and the contractor-onboarding tail.
2. Data model — person vs. application (the v1→v2 correction)
The v1 draft made one table both the nurse and a single pipeline instance. Real recruiting breaks that immediately: nurses re-apply, fit multiple requisitions, and rejected-but-strong "silver medalists" must be re-sourced later. v2 splits identity from instance:
recruitment_candidates= the person. Stable identity, contact, PRC license + verification, experience, EMR/specialties, English level, source + referrer, talent-pool lifecycle (active / dormant / silver_medalist / do_not_contact / hired), the privacy/consent fields (§5), andconverted_contractor_id. One live row per normalized email per tenant (a partial unique index kills duplicates — the v1 gap that would have double-counted every funnel metric and double-enrolled the nurture cadence).candidate_applications= the pipeline instance.candidate_id+ optionalrequisition_id,role_family,scoring_profile,stage,status,fit_score(+ generatedfit_tier, so the board's tier chips can't drift from the score),expected_rate_centavos,owner_id. Unique(candidate_id, requisition_id)— one application per person per req; the same nurse can hold applications to several reqs without forking identity.- Person-level vs application-level children: assessments (incl. PRC/background) and the activity timeline hang off the candidate so they carry forward across applications; interviews and offers hang off the application.
Everything else mirrors the house CRM exactly (text-CHECK stage/status, owner_id, converted_*_id, soft-delete, app.audit_row()/set_updated_at, *_admin_all RLS, PHP *_centavos).
3. Pipeline stages & role-aware scoring
Stages (expanded from v1 to cover the whole funnel the research describes):
sourced → contacted → applied → screening → assessment → interview → offer → pre_boarding → hired
sourced/contactedhold proactive LinkedIn/referral leads who have not applied (the best-quality channels per the research) — so an InMail isn't mislabeled an "application" and the funnel math stays honest.pre_boardingcovers the offer-accepted → contractor-active gap (2–8 weeks) where counteroffers and ghosting peak.status ∈ {open, hired, rejected, withdrawn}; rejected/withdrawn candidates drop into the Talent Pool (talent_pool_status) for re-engagement, not a dead end.
Role-aware fit score (0–100). v1 used one MDS-weighted rubric for everyone, which structurally mis-ranked the 6 non-MDS families. v2 stores a scoring_profile per application and ships four weight sets (pure lib/recruiting/score.ts):
| Dimension | mds | chart_review | care_coordination | generalist |
|---|---|---|---|---|
| MDS/RAI case scenario | 25 | 5 | 0 | 5 |
| Chart-audit / coding accuracy (ICD-10) | 5 | 25 | 5 | 5 |
| Clinical-knowledge test (PDPM, GG) | 15 | 10 | 5 | 5 |
| Care-planning / coordination | 0 | 5 | 25 | 5 |
| US LTC/SNF or US-clinical experience | 20 | 20 | 20 | 25 |
| Written-English / documentation | 15 | 15 | 20 | 20 |
| EMR fluency (PointClickCare > MatrixCare) | 10 | 10 | 10 | 15 |
| Home setup + redundant internet/power | 5 | 5 | 5 | 10 |
| US-night-shift fit + remote discipline | 5 | 5 | 10 | 10 |
PRC verification is a hard gate, not a weight — and in v2 it's enforced in the database: a BEFORE INSERT/UPDATE trigger on candidate_applications blocks any stage beyond screening unless the candidate's prc_verified is true. Tiering (≥75 → 1, 50–74 → 2, <50 → 3) is a generated column.
4. Automated nurture — three cadences (reuses the follow_up_* engine)
The follow-up schema exists, but there is no cadence runner, renderer, enroller, or cron in src/ — all net-new (review ARC-1/DOM-7; corrected from v2's "the engine already exists"). v2 adds a nullable candidate_id to follow_up_enrollments (widening the facility XOR lead target check to num_nonnulls(facility, lead, candidate)=1), a candidate persona, PH channels (viber/messenger/whatsapp alongside sms), and three seeded sequences so the cadence covers the whole journey rather than quitting at Day 10:
- Candidate Nurture 10‑Day (
new_application) — acknowledge in 24h → screen → assessment → interview → offer-decision (6 touches / 10 days). - Pre‑Boarding Anti‑Counteroffer (
offer_accepted) — every 2–3 days to start date (welcome, paperwork/BIR, counteroffer check, equipment, start-ready). - First‑90 Retention (
contractor_activated) — Week‑1 / Day‑30 / Day‑60 / Day‑90 check-ins through the high-attrition window; Day‑90 releases the referral reward.
Email steps draft (see §7 on the new draft state) through the AI orchestrator + email seam and wait for human approval. Auto-enroll fires only on the applied path, so sourced leads never get a "thanks for applying."
5. Privacy & compliance spine (new in v2)
The v1 draft collected sensitive PII with no consent, retention, or audit discipline — the single biggest gap the review found. v2:
- Lawful basis & consent on the candidate:
consent_status,consent_basis(consentvslegitimate_interest),consent_at,privacy_notice_version,data_source_basis. Inbound-applied candidates capture consent at triage; sourced leads record a legitimate-interest basis before advancing. - Retention (RA 10173 storage limitation): the migration seeds
retention_policiesforrecruitment_candidates,candidate_applications,application_raw,candidate_documents. [v3: the purge itself is net-new and unbuilt — the seed is inert until Increment 6 ships aretention-purgecron;retentionStatus()is display-only (CMP-2).] Non-hires are soft-deleted, then hard-deleted past the window including the raw résumé payload and Storage objects. - Audit minimization:
application_rawis not audited (itsrawPII jsonb never reachesaudit_log). [v3: the masking is now DONE in 0054, not deferred —app.audit_row_candidate()+app.mask_recruitment_pii()maskprc_license_no/email/phone/name/parsedfor the candidate identifier tables, enforced by a CI guardrail (CMP-1).] - Cross-border AI: résumé parsing must redact direct identifiers before the OpenAI/Anthropic call and is gated on a signed zero-retention/DPA arrangement (ADR‑0026's own precondition); the privacy notice names the AI processors and the downstream US SNF recipients.
- Misclassification + ethics as a hard pre-hire gate: the compliance checklist is not optional. Six assessment kinds —
bir_registration,contractor_independence_ack,no_fee_attestation,baa_acknowledged,dpa_hipaa_training,secure_workspace_attested— must bepassorapp.hire_candidate()refuses (§7). A Compliance card + gate banner surface this in Candidate 360. (no_fee_attestationevidences the WHO‑Code "employer pays" stance.) - No PHI pre-hire: the pipeline stays upstream of all client PHI; the BAA and EMR access are provisioned only after hire/onboarding/activation.
data_subject_requestslogs access/erasure/correction.
6. The pages (/admin/recruiting)
Beside /admin/contractors and /admin/onboarding. [v3: AdminNav + ⌘K registration is to-be-built (Increment 4), gated behind a fail-closed RECRUITING_ENABLED flag — not yet present (UX-4/BLD-6).] Tabs/pages (see recruitment-pipeline-mockup-v2.html):
- Application Inbox — parsed inbound applications + manual add; per-row Review-&-add / Reject / Merge duplicate (real side-by-side merge modal).
- Pipeline Board — Kanban over the 9 stages with working drag-and-drop, keyboard moves, and a
⋯move-menu; tier chips, PRC-pending flag, days-in-stage, comp-gap chip. - All Candidates — sortable, filterable, paginated table (the recruiter's primary work surface beyond the board) with bulk actions.
- Talent Pool — rejected/dormant/silver-medalist candidates with re-engage.
- Today's Tasks — due nurture touches across all three cadences, grouped by channel.
- Candidate 360 — role-aware score breakdown, PRC + Compliance gate cards, screening scorecard, interviews, documents, dual-timezone scheduling, unified timeline, sequence status, and the gated Hire action.
- Requisitions + Requisition Detail — fill/aging, budget band, per-req funnel.
- Sources & Analytics — channel quality, referral bonuses due, funnel conversion, ghosting rate, 90-day retention.
- Sequences — the three cadence templates and who's enrolled.
UX honors docs/ux-ui-guidelines.md: empty/loading-skeleton/error/validation states everywhere, status by color and text (pills carry dots), aria-selected/role=tabpanel/:focus-visible, 44px touch targets, AA contrast, aria-current nav, native <dialog> modals, mobile card-stack + hamburger.
7. Integration work that is NOT "free reuse" (honest accounting)
The review correctly flagged that several v1 "reuse" claims are net-new code. v2 is explicit about them:
- Transactional hire.
markLeadWon's app-level multi-write rollback is too weak for a 4-write hire. v2 ships aSECURITY DEFINERapp.hire_candidate()RPC (in the migration) that does claim → contractor → 201 → invitation → link in one transaction, gated on PRC + the six compliance assessments, and creates a pendingreferral_rewardsrow. Execute is revoked fromanon/authenticated(mirrors0013). The server action keeps the auth check and calls the RPC. - The follow-up renderer must learn candidates. The existing runner renders facility/lead emails with buyer personas (
administrator/don/...) and facility merge fields. v2 adds acandidatepersona andcand_*/pre_*/ret_*template keys, but the renderer needs a candidate branch + a candidate template registry + candidate merge fields — this is new code, specified, not free. - Email draft lifecycle.
outbound_emails.statushad onlysent/skipped/failed; "draft until approved" was wrongly conflated withskipped. v2 adds a realdraftstatus; the runner insertsdraft, and anapproveAndSend()action callssendEmail()and flips to the terminal status. - Résumé text extraction + Gmail intake. The app generates PDFs but parses none, and the AI boundary takes text only. v2 adds
lib/recruiting/extractText.ts(a PDF→text dependency; DOCX/OCR deferred to phase 2) feedingextractStructured, and a/api/cron/applicationsjob on thecronAuthorized()pattern that upsertsapplication_rawidempotently (dedupe on applicant email, not message-id) and never auto-dismisses a failed parse. - Score recompute is centralized in one
recomputeCandidateScore()called from assessment/interview/candidate writes (not bare stage changes), maintainingscore_updated_at. - Storage uses the admin/company-scoped
facility-contracts(0010) precedent — written into the migration — because pre-hire candidates have nocontractor_id; recruiters upload via service role, downloads via signed URLs. - Standard wiring: Zod schemas (
src/types/schemas/recruiting.ts),src/db/queries,src/server/actions/recruiting.ts, the page, AdminNav + palette, and thesrc/db/types.tsregenerate; Vitest for the pureparse/score/candidateTo201Rowlibs; a feature flag to dark-launch.
8. Schema summary (migration 0054_recruitment_pipeline.sql)
Additive, house-style, parses clean (125 statements). New tables: recruitment_requisitions, recruitment_candidates, candidate_applications, candidate_activities, candidate_assessments, candidate_interviews, candidate_offers, candidate_documents, referral_rewards, data_subject_requests, application_raw. New functions: app.recruitment_prc_gate() (gate trigger) and app.hire_candidate() (transactional, revoked from anon/authenticated). Reused additively: follow_up_enrollments (+candidate_id, widened target check), follow_up_sequences/steps/tasks (widened trigger/channel/persona vocab), outbound_emails (+candidate_id, +kinds, +draft status). Private candidate-documents Storage bucket + admin-scoped policies. Seeds: retention policies + three cadences. Renumbered to 0054 (live head is 0053_facility_pipeline); confirm the next integer before applying. Not applied.
9. Phasing
- Pure core:
lib/recruiting/{parse,score,candidateTo201Row}.ts+ Vitest. - Schema
0054+ regenerate types + seed; theapp.audit_row()masking companion change. - Pipeline UI (manual add → board → All Candidates → Candidate 360) + centralized score recompute + DB PRC gate.
- Intake automation (
extractText+/api/cron/applications) behind a flag. - Nurture: enroll on
applied; the candidate renderer branch +draft/approveAndSend; reuse/api/cron/follow-ups; the pre-boarding + retention cadences. - Hire handoff via
app.hire_candidate()RPC; referral-reward release at Day-90. - ADR‑0042 + CI green (biome + typecheck + guardrails + vitest + build).
10. Open decisions
- Retention windows for non-hires (seeded 1–2 yrs — confirm with counsel).
- Whether to redact-before-AI vs. require a zero-retention DPA only (default: both).
- EOR/Contractor-of-Record vs. direct independent contractor (research §3.2) — affects the compliance gate wording, not the schema.
- Multi-facility requisitions (single
facility_idnow; add a join table if reqs routinely span facilities).