Skip to main content

40. Facility Pipeline — signal-driven business development

Date: 2026-06-17

Status

Proposed

Context

ADR-0033 gave us a CRM pipeline (crm_leads + crm_activities, the /admin/crm Kanban) and a won→client conversion. ADR-0023 added prospect fields to facilities (CMS Five-Star, beds, affiliation) and we imported 576 NY prospect facilities. What we still lack is a way to work that prospect universe systematically: a reason to call a given facility today, a priority order, and an automated follow-up rhythm. Business development is currently manual and memory-driven.

The domain gives us a strong, time-bound buying signal: when a skilled-nursing facility posts a job for an MDS Coordinator/Assessor, PDPM/RAC nurse, chart reviewer, or QM clerk, it has a gap in exactly the function Nightingale sells. Those postings are public and, from compliant sources, cheap to collect. Layer on the public CMS quality data (low QM/Staffing star, recent F641 citations) and the NY Medicaid case-mix transition (which makes MDS accuracy drive Medicaid rates from ~fall 2026), and we can score and tier every facility, then drive a research-backed outreach cadence.

We considered three shapes: (a) a standalone "Facility Pipeline" app area parallel to /admin/crm; (b) bolting signals onto the existing CRM with no new surface; (c) extending the existing CRM/BD surface with new tabs and a scoring/ingestion/follow-up layer. A multi-agent design review (recorded in docs/facility-pipeline/02-review-synthesis.md) rejected (a) as a redundant third BD nav item and rejected (b) as too thin to run a real cadence.

Decision

  • Extend the existing BD surface, don't compete with it. The feature lives under the current "Business development" nav group and reuses crm_leads/crm_activities and the /admin/crm Kanban (ADR-0033). New tabs: Signal Inbox, Today's Follow-ups, Sequences; the existing board and facility detail are reused. No new "Facility Pipeline" nav item competing with "Pipeline".

  • Three additive layers (migration 0053_facility_pipeline.sql):

    1. Signal ingestionjob_raw (append-only landing) + job_signals (normalized, deduped, entity-resolved to a facility; unresolved → a manual-match queue). A facility_aliases table (mirroring billing_payers from 0049) keeps the manual queue small. Ingestion runs through a cron-secret-gated route + a scripts/ingest-job-signals.mjs CLI, writing un-actioned rows for human review (per ADR-0024). Sources are compliant-only (CareerOneStop free API, schema.org JobPosting on robots-allowed career pages, optional licensed vendor APIs); never Indeed or LinkedIn scraping. We store the existence of a posting, never candidate PII.
    2. Scoringfacilities.pipeline_score (0-100) + pipeline_tier (1-3), recomputed in pure src/lib/pipeline/score.ts on ingest and on quarterly CMS refresh, written under an optimistic-concurrency guard (score_updated_at). The model is role-weighted on the posting and deliberately does not double-count the CMS overall star (a composite of QM + staffing — we score the domain stars directly); weights sum to 1.0; tier cutoffs are calibrated against the cohort distribution, not hard-coded.
    3. Follow-up automationfollow_up_sequences/steps/enrollments/tasks drive a cadence (seeded "NY SNF 21-Day", 9 touches). A daily cron materializes due tasks idempotently (unique (enrollment_id, step_no)); email steps are drafted via the AI orchestrator (ADR-0026) into outbound_emails and require human send (no auto-send), consistent with the welcome-email seam (ADR-0035). LinkedIn steps are manual-only and never auto-actioned.
  • Active clients are not prospected. Ingestion checks facilities.status: a signal on an active client routes to an expansion/upsell track assigned to the account owner, not a new-logo prospect lead. A separate Prospect-Discovery view surfaces high-score facilities that have no open posting (CMS-only signals), so the non-signal path is covered.

  • Security per ADR-0004. All writes go through 'use server' actions that verify the caller via getCurrentAdmin, Zod-validate input, and scope every query by company_id. Machine-churn tables (job_raw, job_signals) are not app.audit_row()-audited (daily last_seen_at refresh would flood audit_log; provenance lives in job_raw); the relationship tables (follow_up_*, facility_aliases) are audited like crm_leads. Pure logic in src/lib/pipeline/* is unit-tested.

Consequences

  • The 576 NY prospects become a worked, prioritized queue with a daily worklist and an audit trail of every touch — the central goal.
  • New optional env vars (CAREERONESTOP_API_KEY, etc.) must be .optional() in src/server/env.ts so next build does not fail without them; they are server-only (guardrails forbids NEXT_PUBLIC_*SECRET).
  • src/db/types.ts must be regenerated after the migration; the facility_contacts role Zod schema must widen to the new vocabulary.
  • Scoring is only as good as the CMS data loaded. Loading the CMS Provider Information dataset (free, quarterly) to stamp cms_provider_number is strongly recommended — it both feeds the score and makes entity resolution near-exact (CCN keys), shrinking the manual-match queue.
  • Cadence throughput is bounded by team size; the spec caps active Tier-1 enrollments per rep and uses a condensed Tier-2 nurture so a small team is not swamped.

See docs/facility-pipeline/ for the full design spec, review synthesis, implementation plan, UX spec, and annotated mockups.