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_leadsshape, thefollow_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) | Decision | What changed |
|---|---|---|---|
| C-1 | Migration number 0052 is behind the head — live is at 0053_facility_pipeline and this depends on its objects (data, arch) | Fixed | Renumbered to 0054_recruitment_pipeline.sql; header notes the runner orders by timestamp version, so coordinate the final name. |
| C-2 | Person 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) | Fixed | Split 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-3 | No identity dedup — duplicate humans guaranteed from 2 intake paths; application_raw wrong-grain unique (data, arch, compliance) | Fixed | Partial 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-4 | No consent / lawful basis / retention for candidate PII; raw résumés kept forever; AI ships PRC#/PII cross-border undisclosed (compliance) | Fixed + Documented | Added 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-5 | Audit log copies all PII in cleartext, 10-yr retention, no masking (compliance) | Documented + partial | application_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-6 | Cadence ends Day 10, before the post-offer/counteroffer/pre-start window the research calls highest-risk (domain) | Fixed | Added 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-7 | Faked kanban — hint promises drag/keyboard; markup had none (UX) | Fixed | v2 board has real drag-and-drop, ArrowLeft/Right keyboard moves, a ⋯ move-menu for touch/a11y, focus rings, and per-card aria-label. |
| C-8 | Engine 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 honestly | Design §"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_tierdrift →candidate_applications.fit_tieris a generated column fromfit_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, offersupersedes_offer_id). - Fixed
candidate_documentsnow hasreview_reason/reviewed_by/reviewed_at/issued_on(parity withcontractor_documents);government_idremoved pre-hire (collected only at onboarding). - Fixed trigger naming →
set_updated_at/audit_<table>withdrop trigger if existsguards (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 arestoppedwhen 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 scoring →
candidate_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 justsms); Day-1 touch defaults to Viber. - Fixed referrals →
referred_by_contractor_id+ areferral_rewardsledger (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/UPDATEtrigger oncandidate_applicationsblocksstagebeyondscreeningunless the candidate'sprc_verifiedis 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-controlson tabs;:focus-visiblering; 44px touch targets; darker--subtlefor AA contrast;aria-currentnav; 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_atmaintained; auto-enroll is an explicit insert intofollow_up_enrollments, gated onstage='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_requestslog 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_status ↔ prc_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/ADDCHECK on the sharedfollow_up_*/outbound_emailstables 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_typedoes allow'outsourced'andstatusallows'invited'— the hire RPC's inserts are valid (ARC-8).
CRITICAL / HIGH — fixed in 0054 or the code now
| # | Finding | Disposition |
|---|---|---|
| CMP-1 / PRV-2 | Audit triggers wrote candidate PRC#/email/phone/name in cleartext into a 10-yr audit_log; masking was deferred. | Fixed in 0054 now — app.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-4 | unique(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-6 | Hire 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 0054 — hire_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-5 | Consent/lawful-basis fields existed but gated nothing; a candidate could advance and hire at consent_status='not_collected'. | Fixed in 0054 — recruitment_prc_gate() now also blocks advancement past screening without a recorded lawful basis (the app layer must still write it). |
| DOM-3 | 8 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-upsroute, draft/approveAndSend loop, or referral day-90 transition anywhere insrc/. 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, andsrc/db/types.tslacks 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 timestampversion.)
Deferred to build increments (specified, not yet built)
- CMP-2/CMP-3 retention purge + DSAR erasure (incl.
audit_logsubject-scrub): net-newretention-purgecron; theretention_policiesseed is otherwise inert. (Increment 6.) - ARC-5/MIG-5
outbound_emailshas nobody_htmlandstatus/subject/body_textare 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 absentsrc/db/queries); add a fail-closedRECRUITING_ENABLEDflag toenv.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 → eligibletransition has no actor. (Increment 5.) - DOM-5/DOM-2 offer one-active vs accepted coexistence;
role_familydenormalized 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
- ✅ done branch reconcile:
mainlacked the facility-pipeline migrations, so rebase was moot; the real blocker was stale types — resolved by applying 0054/0055 to the dev DB + regeneratingsrc/db/types.ts. Merge order (facility-pipeline → main → recruitment) is documented in ADR-0042. - ✅ done pure libs (
score/parse/candidateTo201Row/pipeline) + Zod schemas + tests. - ✅ done migration hardening + the CMP-1 masking + guardrail (verified live: audit rows masked).
- ✅ done applied 0054 (+ 0055:
hire_candidateapp→publicso PostgREST can call it) to dev DB; regen types. - ✅ done typed services/actions +
/admin/recruitingUI behindRECRUITING_ENABLED(house components). - ✅ done net-new cadence runner + templates +
/api/cron/follow-ups+approveAndSend+ referral day-90. - ✅ done retention purge (
/api/cron/retention-purge) + DSAR erasure; résumé parser built (services/resumeParser.ts: unpdf PDF→text → deterministic email/phone/PRC →redactForAi→ AIextractStructuredfor non-identifier fields, gated by fail-closedRESUME_AI_ENABLED; manual-upload prefill on Add Candidate). Remaining: the Gmail/api/cron/applicationsauto-fetch cron that feeds résumés into the parser — needs Gmail wiring + the signed zero-retention/DPA arrangement. - ✅ done ADR-0042 + CI green (biome + typecheck + guardrails + vitest + build) on every increment.