Skip to main content

Adversarial Review → v2 — findings, decisions, and changelog

Five adversarial agents red-teamed the v1 Recruitment Pipeline against the live NPM-Helper-App schema and house conventions: (1) data model & migration, (2) UX/UI & accessibility, (3) compliance/legal/privacy, (4) recruiting domain & funnel, (5) architecture & integration. This file records every material finding, the decision (Fixed in v2 / Documented / Deferred), and why. The v2 artifacts are: Recruitment-Pipeline-Design-v2.md, 0054_recruitment_pipeline.sql, recruitment-pipeline-mockup-v2.html.

Headline: the v1 plumbing (reusing crm_leads shape, the follow_up_* engine, outbound_emails, the contractor-onboarding tail) was judged correct against production. The genuine defects were a person/application data-model conflation, a missing privacy/compliance spine, an unenforced PRC + compliance gate, a cadence that quits before the riskiest phase, an MDS-only score applied to a broad-clinical net, and several "reuse is free" claims that are actually net-new code. All Critical findings are addressed in v2.


CRITICAL — all fixed in v2

#Finding (source)DecisionWhat changed
C-1Migration number 0052 is behind the head — live is at 0053_facility_pipeline and this depends on its objects (data, arch)FixedRenumbered to 0054_recruitment_pipeline.sql; header notes the runner orders by timestamp version, so coordinate the final name.
C-2Person vs. application conflation — one row is both the nurse and a single pipeline instance; re-apply, multi-req, and talent-pool re-engagement corrupt or block (data, domain)FixedSplit into recruitment_candidates (stable person/identity/credentials/talent-pool) and candidate_applications (the pipeline instance: stage, status, score, req). Interviews/offers → application; PRC/background/identity & assessments → candidate (carry forward); activities → candidate (+ optional application).
C-3No identity dedup — duplicate humans guaranteed from 2 intake paths; application_raw wrong-grain unique (data, arch, compliance)FixedPartial unique recruitment_candidates(company_id, lower(email)) where email is not null and deleted_at is null; application_raw unique → (company_id, source, external_id); duplicate-merge modal added to the mockup.
C-4No consent / lawful basis / retention for candidate PII; raw résumés kept forever; AI ships PRC#/PII cross-border undisclosed (compliance)Fixed + DocumentedAdded consent_status/consent_basis/consent_at/privacy_notice_version/data_source_basis to candidates; seeded retention_policies for the new tables; documented the status-aware purge cron and the requirement to redact identifiers before AI + gate AI on a signed zero-retention DPA (design §Privacy).
C-5Audit log copies all PII in cleartext, 10-yr retention, no masking (compliance)Documented + partialapplication_raw.raw and candidate_documents.parsed are excluded from auditing (audit trigger omitted on the raw landing table; PII jsonb not snapshotted); a companion change to app.audit_row() to mask prc_license_no/email/phone/name is specified as a required pre-go-live task (can't be fully done without editing the shared function).
C-6Cadence ends Day 10, before the post-offer/counteroffer/pre-start window the research calls highest-risk (domain)FixedAdded two seeded sequences: "Pre-Boarding / Anti-Counteroffer" (offer_accepted, every 2–3 days to start) and "First-90 Retention" (contractor_activated: Wk1/D30/D60/D90).
C-7Faked kanban — hint promises drag/keyboard; markup had none (UX)Fixedv2 board has real drag-and-drop, ArrowLeft/Right keyboard moves, a move-menu for touch/a11y, focus rings, and per-card aria-label.
C-8Engine reuse over-sold as "free" — follow-up renderer only knows facility personas/merge fields; no email draft state; no résumé text-extraction; non-transactional hire (arch)Documented honestlyDesign §"Integration work that is NOT free" now specifies: a candidate branch + persona='candidate' + candidate template registry in the runner; an outbound_emails draft status (added to the CHECK in v2); a new lib/recruiting/extractText PDF→text dep + /api/cron/applications; and a SECURITY DEFINER app.hire_candidate() RPC (added in v2) for an atomic hire.

IMPORTANT — fixed or explicitly deferred

Data model

  • Fixed fit_tier drift → candidate_applications.fit_tier is a generated column from fit_score.
  • Fixed one-active-offer → partial unique candidate_offers(application_id) where status in ('draft','sent').
  • Fixed missing FK indexes (interviewer_id, assessed_by, requisition_id, referred_by_contractor_id, offer supersedes_offer_id).
  • Fixed candidate_documents now has review_reason/reviewed_by/reviewed_at/issued_on (parity with contractor_documents); government_id removed pre-hire (collected only at onboarding).
  • Fixed trigger naming → set_updated_at / audit_<table> with drop trigger if exists guards (idempotent re-run, house style).
  • Fixed soft-delete vs cascade → deletion is soft (deleted_at); hard delete is the gated retention purge; the cadence "due" query filters non-deleted; enrollments are stopped when an application is rejected/withdrawn.

Domain

  • Fixed stages expanded to sourced → contacted → applied → screening → assessment → interview → offer → pre_boarding → hired (sourced/contacted for proactive LinkedIn/referral leads; pre_boarding for the offer-accepted→active gap).
  • Fixed role-aware scoringcandidate_applications.scoring_profile (mds / chart_review / care_coordination / generalist); the v2 design ships four weight profiles so an LPN chart-reviewer isn't scored on an MDS scenario. Candidate 360 renders the profile's bars.
  • Fixed PH channels → cadence/channel vocab adds viber/messenger/whatsapp (not just sms); Day-1 touch defaults to Viber.
  • Fixed referrals → referred_by_contractor_id + a referral_rewards ledger (90-day-active milestone, eligibility, paid).
  • Fixed offers → added expires_at, sign_on_bonus_centavos, acceptance_type, lost_to, competing_offer_centavos, version, supersedes_offer_id.
  • Fixed requisitions → opened_at (aging), budget_min/max_centavos; per-req funnel + fill/aging in the Requisition Detail page; multi-facility noted as a follow-up join.

Compliance / UX / Arch

  • Fixed PRC hard gate enforced in the DB → a BEFORE INSERT/UPDATE trigger on candidate_applications blocks stage beyond screening unless the candidate's prc_verified is true (defense-in-depth, not prose).
  • Fixed pre-hire compliance gate → assessment kinds bir_registration, contractor_independence_ack, no_fee_attestation, baa_acknowledged, dpa_hipaa_training, secure_workspace_attested; app.hire_candidate() refuses unless the required ones pass; a Compliance card + gate banner added to Candidate 360.
  • Fixed candidate Storage RLS uses the admin-scoped facility-contracts (0010) precedent, written into the migration (not "added separately"), keyed "<candidate_id>/<file>"; downloads via signed URLs; recruiter uploads via service role.
  • Fixed (UX) empty / loading-skeleton / error / inline-validation states; sortable All Candidates table; Talent Pool; Requisition Detail; real(ish) filters + an honest "sample data" banner; aria-selected/role=tabpanel/aria-controls on tabs; :focus-visible ring; 44px touch targets; darker --subtle for AA contrast; aria-current nav; pill dots so status isn't color-alone; dual-timezone interview display.
  • Fixed (arch) score recompute centralized in one recomputeCandidateScore() called from assessment/interview/candidate writes (not on bare stage change); score_updated_at maintained; auto-enroll is an explicit insert into follow_up_enrollments, gated on stage='applied' (applied path) so sourced leads don't get "thanks for applying."
  • Deferred (documented): DOCX/scanned-résumé OCR (phase 2 — PDF text first); multi-facility requisition join; a full DSAR admin UI (a data_subject_requests log table is added now); feature-flag rollout (noted in phasing).

MINOR — applied where cheap, else noted

Email/--subtle contrast darkened; required/optional marked on every field; rate_period*_centavos CHECK ("both null or both set"); english_level label vs scored writing-sample reconciled (writing-sample score drives the rubric; label is descriptive); ghosting flag derivable from missed follow_up_tasks (metric added to analytics); KPI set expanded (offer-accept rate, source quality, ghosting rate, 90-day retention, time-in-stage); comp-gap-to-budget chip on cards; candidate_documents.review_statusprc_verified linked through the PRC-verification assessment as the single source of truth; bi-monthly rate conversion (expected_rate_centavos monthly/hourly → contractor bimonthly_rate_centavos) specified in the hire mapper; src/db/types.ts regenerate + AdminNav/CommandPalette edits + Vitest for pure libs enumerated in phasing.

What we deliberately did NOT change (to avoid over-correcting)

The num_nonnulls(facility_id, lead_id, candidate_id)=1 rewrite of the XOR (sound, and the live constraint name follow_up_enrollments_target_chk is confirmed); outbound_emails.candidate_id ON DELETE SET NULL (a send-ledger should survive the person); PHP *_centavos/text-CHECK/company_id/*_admin_all RLS conventions; the hire-handoff boundary (pipeline ends where onboarding begins). The stale sibling 0050_facility_pipeline.sql in this folder is not the oracle — v2 targets the live schema (enrollment_trigger, not trigger).


Second adversarial review (v2 → v3)

A second review re-ran the same five-front method, but this time grounded against the live Supabase schema (dksjqoknmwpxfqkiygkb) and the real src/ code, not the design's self-description: 7 dimensions, 48 findings raised, 44 survived independent adversarial verification (3 refuted: ARC-6, UX-1, BLD-7). Disposition below — what shipped into 0054 / the code now, what is built, what is deferred to a later build increment, and what is gated on the owner.

What grounding CONFIRMED about v2 (the hardening was real)

  • Every high-risk DROP/ADD CHECK on the shared follow_up_* / outbound_emails tables is a clean superset of the live constraint — applying 0054 will not break or violate existing rows (MIG-6).
  • Every column app.hire_candidate() writes exists in live; contractor_type does allow 'outsourced' and status allows 'invited' — the hire RPC's inserts are valid (ARC-8).

CRITICAL / HIGH — fixed in 0054 or the code now

#FindingDisposition
CMP-1 / PRV-2Audit triggers wrote candidate PRC#/email/phone/name in cleartext into a 10-yr audit_log; masking was deferred.Fixed in 0054 nowapp.audit_row_candidate() + app.mask_recruitment_pii() ship in the migration and are wired to the three identifier tables; the other 30+ audited tables keep the unmasked app.audit_row(). A new guardrail (scripts/guardrails.mjs) fails CI if a candidate PII table is ever wired to the unmasked function.
MIG-1 / DOM-4unique(candidate_id, requisition_id) is a no-op when requisition_id IS NULL → unbounded duplicate pipeline rows on the dominant sourced/manual path.Fixed in 0054 — added partial unique(candidate_id) where requisition_id is null and deleted_at is null.
MIG-2 / PRV-6Hire compliance gate used count(distinct kind where pass), so a stale pass masked a newer fail; the in-function PRC check was dead code (the claim-UPDATE's prc_gate fired first).Fixed in 0054hire_candidate() now resolves+locks the application, runs PRC + latest-assessment-per-kind gates on current state before the claim, then withdraws the candidate's other open applications and refuses a double-hire.
CMP-5Consent/lawful-basis fields existed but gated nothing; a candidate could advance and hire at consent_status='not_collected'.Fixed in 0054recruitment_prc_gate() now also blocks advancement past screening without a recorded lawful basis (the app layer must still write it).
DOM-38 role_family values mapped to 4 scoring_profiles with no mapping; default generalist silently mis-scored specialists.Built (Increment 1)ROLE_FAMILY_TO_SCORING_PROFILE + scoringProfileForRole() in src/lib/recruiting/pipeline.ts, unit-tested; the application action must set scoring_profile from it.
CMP-4"Redact identifiers before AI" was asserted/rendered-done but no such code existed.Primitive built (Increment 1) — deterministic redactForAi() in src/lib/recruiting/parse.ts (email/phone/PRC), tested. The AI call wiring + DPA gate remain in Increment 6.

HIGH — re-scoped honestly (the "reuse" framing was wrong)

  • ARC-1/ARC-2/BLD-2/PRV-1/DOM-7 — the follow_up_* "engine" is schema-only. There is no runner, renderer, enroller, /api/cron/follow-ups route, draft/approveAndSend loop, or referral day-90 transition anywhere in src/. The three seeded cadences are inert until built. This is net-new code, re-scoped into Increment 5 — not a free reuse. (Design §4/§7/§9 corrected.)
  • ARC-3/BLD-1/ARC-4/PRV-3 — branch not buildable from empty + stale types. This branch's migrations jump 0048 → 0054; the four follow_up_* tables 0054 ALTERs are created only in 5 live-only migrations absent here, and src/db/types.ts lacks facility-pipeline and recruitment. Gated on the owner: rebase onto the line carrying the facility-pipeline migrations, then regenerate types — see the build sequence. (Migration "0054" number is cosmetic; the runner orders by timestamp version.)

Deferred to build increments (specified, not yet built)

  • CMP-2/CMP-3 retention purge + DSAR erasure (incl. audit_log subject-scrub): net-new retention-purge cron; the retention_policies seed is otherwise inert. (Increment 6.)
  • ARC-5/MIG-5 outbound_emails has no body_html and status/subject/body_text are NOT NULL with no default — the draft contract must store rendered HTML and always set status. (Increment 5.)
  • ARC-9 résumé PDF→text is fully net-new (only @react-pdf/renderer, generation-only); sandbox + Zod-validate at the boundary. (Increment 6.)
  • BLD-3/BLD-6 put data access in src/server/services + src/server/actions (not the absent src/db/queries); add a fail-closed RECRUITING_ENABLED flag to env.ts. (Increments 2/4.)
  • DOM-1/PRV-4 candidate identity needs an app-level find-or-create (ON CONFLICT/23505); the unique index alone hard-fails the manual-add path. (Increment 4.)
  • DOM-6 referral reward day-90 pending → eligible transition has no actor. (Increment 5.)
  • DOM-5/DOM-2 offer one-active vs accepted coexistence; role_family denormalized in 3 tables. (P2.)

UX (mockup is a reference, not the markup source) — Increment 4

Confirmed: tables won't card-stack on mobile (UX-2), sort headers are mouse-only with no aria-sort (UX-3), some touch targets < 44px (UX-5), PRC status is color-only (UX-8), destructive actions skip the house DangerConfirm (UX-7), the mockup nav doesn't match the real AdminNav (UX-4). Build on the house Table/SortHeader/PipelineBoard/confirmDanger components, not the bespoke mockup markup.

Build sequence (corrected phasing) — all built except the gated AI intake

  1. ✅ done branch reconcile: main lacked the facility-pipeline migrations, so rebase was moot; the real blocker was stale types — resolved by applying 0054/0055 to the dev DB + regenerating src/db/types.ts. Merge order (facility-pipeline → main → recruitment) is documented in ADR-0042.
  2. ✅ done pure libs (score/parse/candidateTo201Row/pipeline) + Zod schemas + tests.
  3. ✅ done migration hardening + the CMP-1 masking + guardrail (verified live: audit rows masked).
  4. ✅ done applied 0054 (+ 0055: hire_candidate apppublic so PostgREST can call it) to dev DB; regen types.
  5. ✅ done typed services/actions + /admin/recruiting UI behind RECRUITING_ENABLED (house components).
  6. ✅ done net-new cadence runner + templates + /api/cron/follow-ups + approveAndSend + referral day-90.
  7. ✅ done retention purge (/api/cron/retention-purge) + DSAR erasure; résumé parser built (services/resumeParser.ts: unpdf PDF→text → deterministic email/phone/PRC → redactForAi → AI extractStructured for non-identifier fields, gated by fail-closed RESUME_AI_ENABLED; manual-upload prefill on Add Candidate). Remaining: the Gmail /api/cron/applications auto-fetch cron that feeds résumés into the parser — needs Gmail wiring + the signed zero-retention/DPA arrangement.
  8. ✅ done ADR-0042 + CI green (biome + typecheck + guardrails + vitest + build) on every increment.