Skip to main content

Adversarial review — BD & HR/Recruitment menus, workflows, and Kanban boards

Date: 2026-07-02 · Scope: /admin/crm (Business Development) and /admin/recruiting (HR/Recruitment), their boards, actions, services, and the Supabase objects they touch. Read-only review; no code changed.

Every claim is cited path:line and tagged [confirmed] (read in code) or [inferred] (deduced from code, not executed).


0. Ground-truth discrepancies (read this first)

The product context supplied for this review disagrees with the repository in three load-bearing ways. Per instructions, the code wins:

  1. There is one tenant, not two. The schema is multi-tenant-ready (company_id on every table), but exactly one company is seeded — 'Nightingale Process Management, L.L.C.' (src/db/migrations/0002_core_schema.sql:507-508; comment "one company seeded for now" at 0002_core_schema.sql:5). Admin bootstrap assigns every new admin to the first seeded company with an explicit TODO (src/server/auth/profile.ts:36). Ability Builders does not exist in this codebase — its only trace is the owner's @abckidsny.com inbox mentioned in email docs (docs/email-settings/CRM-EMAIL-TRACKING-PLAN.md:75). Everything below about "AB's relationship funnel" is therefore an audit of capability, not of a live tenant. [confirmed]
  2. The BD schema is not accounts / contacts / opportunities / activities / pipeline_stages. It is crm_leads (account + opportunity + primary contact denormalized into one row) + crm_activities (src/db/migrations/0032_crm_leads.sql:13-50). There is no pipeline_stages table anywhere — stages are CHECK constraints in the DB (0032:17-18, 0054:214-215) mirrored by hardcoded arrays in src/lib/crm/pipeline.ts:10 and src/lib/recruiting/pipeline.ts:15-25. [confirmed]
  3. RLS exists but is not the enforcement layer for these pages. Both areas read and write through the service-role client, which bypasses RLS (src/db/clients/service.ts:6-15). Tenant scoping on these paths is explicit .eq('company_id', admin.companyId) filters in app code (per ADR-0004, docs/adr/0004-rls-and-service-role.md). RLS policies do exist on every touched table as defense-in-depth for user-scoped clients — see §5. [confirmed]

1. Inventory

AreaMenu / routePage componentsClient componentsServer actions / servicesSupabase objects
BD menu"Business development" group → "Grow Business Pipeline" (/admin/crm), "Manage Facilities" (/admin/facilities) — src/components/admin/AdminNav.tsx:26-41; domain-filtered AdminNav.tsx:159-168AdminNav, AdminShellgetAdminLayoutContext (src/server/auth/access.ts:135-188)profiles, settings (access_roles)
BD pipeline/admin/crm (gate: requireAccess('business_dev') in src/app/admin/crm/layout.tsx:4)src/app/admin/crm/page.tsx (tabs: Today / Signal Inbox / Pipeline Board / Prospect Discovery / Sequences, page.tsx:75-132)PipelineBoard.tsx, SignalInbox, TodayFollowUps, ProspectDiscovery, SequencesPanel, PipelineKpis, RaiseLeadModal, FilterBox, EmptyStatesrc/server/actions/crm.ts (create/update/move/won/lost/activity), src/server/actions/pipeline.ts (21 signal/sequence actions), src/server/services/crm.ts, src/db/queries/pipeline.ts (50+ fns)crm_leads, crm_activities (0032); job_raw, job_signals, facility_aliases, follow_up_sequences/steps/enrollments/tasks (0053); facilities, facility_contacts (0002/0003); outbound_emails
BD detail/admin/crm/[id], /admin/crm/new, /admin/crm/exportcrm/[id]/page.tsx, crm/new/page.tsxLeadForm, LeadDetailActions (LeadStageActions, WonDialog, LostDialog, LeadActivityForm)updateLead, moveLeadStage, markLeadWon, markLeadLost, logLeadActivity (actions/crm.ts:117-377)crm_leads, crm_activities, facilities, facility_contacts
Relationship maintenance (post-sale)/admin/facilities (+ /admin/renewals, both business_dev domain)facilities/page.tsx (sortable by last_contacted_date, next_follow_up_datefacilities/page.tsx:35,81), facilities/[id] Relationship + Cadence tabs ([id]/page.tsx:376-424, 225-305)FacilityRelationship.tsx, EnrollSequenceButton, FacilityQuickShelflogFacilityNote (updates last_contacted_date, facilities.ts:848-854), updateFacilityRelationship (facilities.ts:900-931), runFacilityCadenceEngine (facility-cadence.ts:171-270), contractAlerts 90/60/30 renewal logic (src/lib/relationship/index.ts:21-50)facilities (last_contacted_date, next_follow_up_date 0031, contract_renewal_date/review_date, relationship_notes), facility_notes, follow_up_*
Recruiting"Recruit Talent" group → "Find New Talent" (/admin/recruiting, feature-flagged AdminNav.tsx:50, re-checked server-side recruiting/page.tsx:82 and per-action actions/recruiting.ts:67); gate requireAccess('recruiting') in recruiting/layout.tsx:4recruiting/page.tsx (8 tabs incl. Pipeline Board page.tsx:254-309), recruiting/[id]/page.tsx (Candidate 360), recruiting/new/page.tsxBoard is inline JSX in the page (no shared board component); CandidateActions.tsx (move stage / interview / offer / reject / hire), AddCandidateForm, RecruitingInbox, RecruitingTalentPool, RecruitingToday, RecruitingRequisitions, RecruitingAnalytics, RecruitingSequencessrc/server/actions/recruiting.ts (17 actions), src/server/services/recruiting.ts, src/server/services/followUp.tsrecruitment_candidates, candidate_applications, candidate_activities/assessments/interviews/offers/documents, recruitment_requisitions, referral_rewards, application_raw, data_subject_requests (all 0054); RPCs hire_candidate (0055→0069), recruiting_application_stats, recruiting_source_stats; follow_up_* (shared with BD)
Hire → onboarding seam/admin/onboardingonboarding/page.tsxCandidateActions.HireFormhireCandidate (actions/recruiting.ts:940-994) → public.hire_candidate RPCcontractors, contractor_201_details, contractor_invitations, contractor_facility_assignments

Note: the two boards do not share a component. BD uses src/components/admin/PipelineBoard.tsx (drag-and-drop client component); recruiting renders static columns inline (recruiting/page.tsx:261-308). "Shared Kanban abstraction" exists only as a visual pattern. [confirmed]


2. Flow verdicts

2.1 BD board — Works (one failure-path defect, one dead-end field)

Card lifecycle trace:

  1. Load: crm/page.tsx:46-49listOpenLeads(svc, admin.companyId, {q, ownerId})crm_leads filtered company_id + status='open' + deleted_at is null, ordered updated_at desc (src/server/services/crm.ts:47-56). No pagination. [confirmed]
  2. Render: one column per hardcoded stage ['new','contacted','qualified','proposal'] (lib/crm/pipeline.ts:10; PipelineBoard.tsx:78). Card shows name, $value, city/state, owner, next-follow-up (PipelineBoard.tsx:125-150); column header shows count + $ sum (101-107).
  3. Move: native HTML5 drag (onDrop PipelineBoard.tsx:90-96) or per-card "Move to…" menu (110-117) — the menu is the touch/keyboard fallback. Optimistic: card state flips immediately (58), a pending ref suppresses prop re-sync mid-flight (43-49).
  4. Persist: moveLeadStage (actions/crm.ts:153-183): gate requireDomain('business_dev') (157), Zod stage enum (types/schemas/crm.ts:31-33 — only the 4 open stages are accepted, so you cannot move to a terminal state through this action), guarded UPDATE … eq(company_id).eq(status,'open') returning rows (169-178). 0 rows → "Lead not found or closed."
  5. Refetch: success → toast + router.refresh() (PipelineBoard.tsx:66-67) plus server revalidatePath (crm.ts:180-181). Server error (res.ok === false) → per-card revert to captured fromStage + danger toast (69-72). Concurrent moves of different cards are safe (per-card revert, not snapshot revert — comment at 55-57). Duplicate cards impossible (single array map). [confirmed]
  6. Break point — network failure mid-drag: await moveLeadStage(...) at PipelineBoard.tsx:63 has no try/catch. If the server-action fetch itself rejects (offline, deploy blip), the exception escapes the handler: no revert, no toast, and pending.current is never decremented (64 is after the await), so the pending === 0 guard at 47-49 permanently blocks re-sync from server props for the rest of the page's life. The card sits in a column the DB never saw, and even router.refresh() can't correct it until a full reload. [inferred from code — not executed]
  7. Terminal transitions live on the detail page by design (PipelineBoard.tsx:35): "Advance" one stage, "Mark won → client", "Mark lost" (LeadDetailActions.tsx:200-238). markLeadWon is claim-first and race-safe: guarded open→won UPDATE, loser gets 0 rows and bails before creating anything (actions/crm.ts:281-297); facility-insert failure releases the claim (306-311) — though the revert leaves converted_at stamped on the reopened lead (cosmetic). Won leads convert to facilities + seeded facility_contacts (299-324; mapping in lib/crm/pipeline.ts:42-75). [confirmed]
  8. Dead end — reheat_date: markLeadLost stores an optional reheat date (crm.ts:365; UI LeadDetailActions.tsx:139-143), and it is never read anywhere — no query, no cron, no KPI, no list (repo-wide grep: writes only). "Timing — revisit later" leads never resurface. [confirmed]

2.2 Recruiting board — Partial (renders correctly; it is not actually a Kanban)

  1. Load: recruiting/page.tsx:122-128listBoardApplications(svc, admin.companyId, {q, ownerId, requisitionId})candidate_applications + joined candidate, filtered company_id + status='open', ordered updated_at desc (services/recruiting.ts:44-64). Free-text search filters in JS after fetching all rows (59-62). No pagination. [confirmed]
  2. Render: 9 hardcoded columns sourced…hired (lib/recruiting/pipeline.ts:15-25; recruiting/page.tsx:262), horizontally scrolling (261). Card = name, fit tier/score badge, "PRC pending" blocker badge, role family, expected rate (274-301).
  3. Move: there is no move. Cards are plain <Link>s to the Candidate 360 page (276-279). No drag-and-drop, no per-card menu, no optimistic update — the entire drag/persist/rollback branch of the checklist is vacuous for this board. Stage changes happen only via the MoveStageForm select on the detail page (CandidateActions.tsx:124-151moveApplicationStage, actions/recruiting.ts:755-790). Round trip to move one candidate: board → detail → select → submit → back. [confirmed]
  4. Persist path (from detail): gate = feature flag + requireDomain('recruiting') (actions/recruiting.ts:66-71); guarded company-scoped UPDATE (771-778); DB BEFORE trigger enforces the PRC + lawful-basis gates on any advance past screening (0054_recruitment_pipeline.sql:274-294), surfaced as friendly errors (recruiting.ts:779-786). This is stronger transition enforcement than the CRM board has — and it's invisible from the board itself.
  5. Filter defect: the Requisitions tab deep-links the board filtered by requisition (RecruitingRequisitions.tsx:250?tab=board&req=<id>), but the board's filter form carries only tab as a hidden field (recruiting/page.tsx:195) — submitting a search silently drops the req filter, and nothing on screen indicates a requisition filter is active. [confirmed]
  6. Hire → onboarding handoff — Works, genuinely transactional: hireCandidate (actions/recruiting.ts:940-994) calls public.hire_candidate (SECURITY DEFINER, execute revoked from anon/authenticated — 0055:116). In one transaction it: locks the application FOR UPDATE (0055:34-37), re-checks PRC + the six compliance passes (0055:48-67), flips the application hired/hired, auto-withdraws the candidate's other open applications (0055:69-75), creates contractors (status invited), contractor_201_details, contractor_invitations (0055:77-98), links converted_contractor_id, seeds a referral reward, logs a system activity (0055:100-111), and since 0069 optionally sets position config + first facility assignment (0069:90,103-109). The new contractor then appears on /admin/onboarding automatically because that page lists contractors by status incl. invited (onboarding/page.tsx:19-24,36). No dead end, no manual re-entry. [confirmed]

2.3 Menus — Work

Nav items are filtered by permitted access domains with owner-only and feature-flag modifiers (AdminNav.tsx:159-168); every route re-enforces server-side in its segment layout (crm/layout.tsx:4, recruiting/layout.tsx:4, facilities/layout.tsx:4, plus flag re-checks at recruiting/page.tsx:82, recruiting/[id]/page.tsx:32, recruiting/new/page.tsx:8) and per-mutation via requireDomain (access.ts:95-109). "View as" impersonation is read-only for mutations (access.ts:100-103). [confirmed]


3. Goal-alignment scorecards

3.1 Nightingale BD (outbound sales pipeline) — fit 4/5

Required signalPresent?Evidence / notes
Opportunity value✅ card + per-column sum + header totalPipelineBoard.tsx:106,135; crm/page.tsx:179
Expected placement start⚠️ stored, not on boardexpected_close_date (0032:29, editable LeadForm) — never rendered on a card
Facility size/type❌ on leadscrm_leads has only city/state (0032:25-26); beds/type live on facilities, i.e. post-conversion
Stage✅ columnsPipelineBoard.tsx:78
Days-in-stage / stale-deal flagNo stage timestamp exists on crm_leads (0032:13-37 — only updated_at). Not derivable in UI; audit_log rows exist (0032:58-61) but nothing reads them for this
Owner✅ card + filterPipelineBoard.tsx:141-143; crm/page.tsx:96-105
Next action + due datenext_follow_up_date on card (PipelineBoard.tsx:144-149); overdue surfaced in Today tab + KPIs (queries/pipeline.ts:769-841, PipelineKpis)
Decision-maker linkage⚠️ single denormalized contactcontact_name/email/phone on the lead row (0032:22-24); multi-threading personas exist only on facility contacts/cadence steps (0053), not leads
Close-probability / forecastNothing; column sums are the only forecast proxy
Activity history✅ on detailcrm_activities timeline (crm/[id]/page.tsx:147-166) with one-click stage advance (LeadDetailActions.tsx:296-298)

Justification: the surrounding workflow (signal inbox → raise lead → cadence enrollment → today worklist) is a well-built, company-scoped outbound machine (see agent-verified trace of raiseLeadFromSignal, actions/pipeline.ts:160-260). The board itself is the weakest tab: it shows what is in each stage but not how long or how stale.

3.2 Ability Builders BD (relationship-maintenance funnel) — N/A as a tenant; 1/5 if forced onto this Kanban; ~3/5 for the facilities surface that actually does this job

  • AB does not exist in the code (§0.1). Scoring "AB's UI" is scoring a hypothetical.
  • If AB were dropped onto the CRM Kanban as-is, the mismatch the review brief predicts is real and hard-confirmed: the funnel is strictly linear with a terminal won that converts the lead into a facilities row and removes it from the board (lib/crm/pipeline.ts:5-7,42-48; actions/crm.ts:283-297), stages are Nightingale-specific and hardcoded (lib/crm/pipeline.ts:10), card value is USD deal size (PipelineBoard.tsx:29,135), the signal engine ingests US SNF job postings (0053 job_signals.role_family CHECK is MDS/PDPM vocabulary), and the seeded cadence is "NY SNF 21-Day" (0053:311-352). There is no last-touch date, relationship-health, or referral-volume signal anywhere on the board. Flagging hard, as requested: modeling a relationship-maintenance funnel on this board would be a goal mismatch — but today no such funnel is on it. [confirmed capability, inferred consequence]
  • The relationship-maintenance job is actually implemented — in Facilities, not the Kanban: last_contacted_date auto-updated on every logged touch (facilities.ts:848-854), sortable last-touch / next-follow-up columns on the list (facilities/page.tsx:35,81), 90/60/30 contract-renewal and review alerts (lib/relationship/index.ts:21-50, rendered facilities/[id]/page.tsx:179-186,536-544), an activity timeline merging facility notes + pre-sale CRM history (queries/pipeline.ts:582-651), and enrollable multi-touch cadences with an XOR facility/lead target (0053 follow_up_enrollments CHECK). This is a steady-state, cyclical surface — no terminal stage. What it lacks vs. the checklist: an explicit overdue-for-contact cadence signal (there is no "X days since last touch vs. expected cadence" computation; only sortable raw dates) and any referral-recency/volume concept (no referral entity exists on the BD side at all). [confirmed]

3.3 HR / Recruitment — fit 3/5

Required signalPresent?Evidence / notes
Time-in-stage / bottleneck visibilityNo stage timestamp on candidate_applications (0054:201-247 — only updated_at, applied_at, score_updated_at). Requisition funnel counts exist (services/recruiting.ts:245-296) but no dwell time
Candidate source⚠️On candidates table + Sources & Analytics tab with per-source conversion (services/recruiting.ts:531-592); not on board cards
Target role✅ cardrole_family on card (recruiting/page.tsx:298)
Target org / requisition⚠️Filterable via deep-link (RecruitingRequisitions.tsx:250) but the filter is invisible and dropped by the search form (§2.2.5); req title not on card
WIP limits per stageNothing anywhere; columns show raw counts only (recruiting/page.tsx:271)
Interview scheduling / next step + owner❌ on boardInterviews exist (CandidateActions.tsx:206-255, PHT-aware actions/recruiting.ts:305) but only on detail. candidate_applications.next_action_date is a dead column: created + indexed (0054:233,254), fetched by the board query (services/recruiting.ts:41), and never written nor rendered anywhere in src/ (repo-wide grep). owner_id is fetched and filterable but not shown on cards
Blocker/stall flag⚠️"PRC pending" badge is a real blocker flag (recruiting/page.tsx:291-295); no time-based stall flag
Hire → onboarding handoff✅✅Transactional RPC, §2.2.6 — the best seam in either area

Justification: compliance rails are excellent (PRC/consent DB trigger 0054:274-294, six-check hire gate 0055:52-67, RA 10173 consent + erasure actions/recruiting.ts:196-227,1069-1112). But the board itself is a read-only funnel chart: recruiters manage the pipeline from detail pages and the Today tab, and the three signals a recruiting Kanban exists to show — dwell time, next action, owner — are absent or dead.


4. Kanban conformance checklist

CheckBD boardRecruiting board
Columns data-driven from pipeline_stages❌ No such table exists. Stages hardcoded (lib/crm/pipeline.ts:10) mirroring a DB CHECK (0032:17-18). Adding a stage = migration + code change in lockstep❌ Same pattern (lib/recruiting/pipeline.ts:15-250054:214-215; lockstep warning in the lib header pipeline.ts:8)
Drag-and-drop persists; optimistic + rollback; no lost/duped cards✅ mostly (PipelineBoard.tsx:54-74) — except unhandled promise rejection on network failure leaves a phantom move and permanently blocks prop re-sync (§2.1.6) [inferred]N/A — no drag-and-drop exists (recruiting/page.tsx:274-301)
Intra-column reorder persistsN/A — not a feature; no rank/position column on either table (0032:13-37, 0054:201-247). Order = updated_at desc (services/crm.ts:55, services/recruiting.ts:57), so a moved card jumps to column top [confirmed]N/A — same
Multi-tenant integrity✅ RLS on crm_leads/crm_activities keyed app.is_admin() AND company_id = app.current_company_id() (0032:71-80; helpers 0003:9-25). But the app path bypasses RLS (service client, service.ts:6-15) — enforcement is explicit .eq('company_id') on every query/mutation, verified across actions/crm.ts (e.g. 172-175, 290-293), services/crm.ts:50, and all 21 actions/pipeline.ts actions (agent-verified, none missing)✅ Same posture: RLS on all 12 recruiting tables (0054:187-560), hire RPC execute revoked (0055:116), storage policies company-checked (0054:621-642); app path = service client + explicit scoping incl. ownsCandidate/ownsApplication guards for client-supplied IDs (actions/recruiting.ts:74-96). Nit: getCandidate360 sub-queries scope by candidate_id only after the candidate is company-verified (services/recruiting.ts:163-204) — transitively safe, weaker as defense-in-depth
Filter/sort by owner, staleness, value/last-touch⚠️ owner + name search only (crm/page.tsx:91-106); no staleness or value sort⚠️ recruiter + name search (recruiting/page.tsx:193-229); requisition filter exists but is invisible and fragile (§2.2.5); no staleness
Empty states✅ board-level with filter-aware copy (crm/page.tsx:110-118) + per-column "No leads" (PipelineBoard.tsx:155-157)⚠️ board-level ✅ (recruiting/page.tsx:255-259); no per-column empty state — an empty column is a bare header + "· 0"
Loading + error statescrm/loading.tsx (skeleton) + crm/error.tsx existrecruiting segment has neither loading.tsx nor error.tsx (dir listing: only layout.tsx, page.tsx, [id], new) — fetch failure bubbles to the root admin error boundary; slow loads show nothing
Performance at 200+ cards⚠️ no pagination (services/crm.ts:42-57), no virtualization; fine at current single-company scale, full-page render at scale [inferred]⚠️ same, plus free-text search filters in JS after fetching all rows (services/recruiting.ts:59-62)
Accessibility⚠️ native HTML5 drag is not keyboard-operable, but the per-card RowActions "Move to…" menu is the compensating keyboard path (PipelineBoard.tsx:110-117); columns have aria-label (83-84); result announced via toast✅/⚠️ cards are plain links with visible focus (recruiting/page.tsx:278) — accessible because there's nothing to operate; columns labeled with counts (267)
Responsive⚠️ columns stack vertically on mobile (grid-cols-1 sm:2 xl:4, PipelineBoard.tsx:77) — no horizontal board scroll; consistent with the app's tables→cards rule but loses the board metaphor✅ classic horizontally scrolling board (overflow-x-auto, min 15rem columns, recruiting/page.tsx:261). The two boards chose opposite responsive strategies
Card → detail uses the app's shelf pattern❌ both boards navigate to full pages (PipelineBoard.tsx:126-131, recruiting/page.tsx:276). The slide-out shelf pattern exists and is used by Invoicing (components/admin/invoicing/InvoiceShelf.tsx, FacilityShelf.tsx) and Facilities (FacilityQuickShelf.tsx) — BD/recruiting cards are the inconsistency❌ same

5. High-blast-radius flags (RLS / multi-tenant / PII — rotate a second reviewer before changing any of these)

  1. Money mutation not owner-gated despite its own comment. markReferralPaid is documented "(owner-only — it's money out)" but is gated only by requireDomain('recruiting') (actions/recruiting.ts:371-377); any staff member with the recruiting domain can mark referral bonuses paid. The UI hides the pay control for non-owners (recruiting/page.tsx:250 passes canPay={admin.role==='owner'}), so the server is weaker than the UI. [confirmed]
  2. hireCandidate server action not owner-gated while the UI is. HireForm renders only for owners (CandidateActions.tsx:554-562), but the action accepts any recruiting-domain admin (actions/recruiting.ts:940-943). Hire creates a contractor + invite (downstream payroll identity). DB gates (PRC/compliance) still apply, but the owner restriction is client-side only. [confirmed]
  3. Service-role bypass is the norm on these pages (service.ts:6-15): every listed page/action's tenant isolation rests on hand-written company_id filters. All were verified present (§4), but any future query added without the filter leaks silently in a two-tenant world. RLS would not catch it on this path.
  4. Single-tenant bootstrap: every new admin is attached to the first seeded company (profile.ts:36). Onboarding a second tenant (e.g. Ability Builders) without fixing domain→company mapping would merge both companies' admins into one tenant. Pre-existing TODO; blocking for any two-tenant plan.
  5. Candidate PII sub-queries scoped transitively, not directly (services/recruiting.ts:163-204): applications/assessments/documents/activities are fetched by candidate_id alone after a company-scoped candidate fetch. Safe today; add company_id for depth.
  6. DSAR ledger retains cleartext subject email by accepted decision (actions/recruiting.ts:1093-1103); application_raw deliberately not audited to keep résumé PII out of audit_log (0054:561). Both are documented decisions — listed here for visibility, not as defects.

6. Central-question verdict: keep the boards forked (they already are) — configure nothing, build no shared abstraction

The premise of the question — "two funnels share a Kanban abstraction" — is false in code. There is no shared board component, no pipeline_stages table, no per-workflow configuration surface. What exists is:

  • BD: an interactive 4-column drag board over crm_leads (PipelineBoard.tsx), embedded in a signal→cadence workflow that is the real product.
  • Recruiting: a 9-column read-only funnel view inlined in the page (recruiting/page.tsx:261-308), where the real controls live on the Candidate 360 page behind DB-enforced compliance gates.
  • Relationship maintenance: not on any Kanban — it lives in Facilities (last-touch, renewal alerts, cadences), correctly modeled as steady-state rather than terminal.

Evidence-based reasoning against each alternative:

  • "Keep shared" — nothing is shared to keep.
  • "Configure per workflow" (data-driven pipeline_stages) — actively harmful here: both stage vocabularies are load-bearing in the database. Recruiting stages are wired into a CHECK constraint (0054:214-215), the PRC/consent BEFORE trigger names specific stages (0054:277), and the hire RPC writes stage='hired' (0055:70). CRM stages back the won-conversion contract (lib/crm/pipeline.ts:26-30,42-48). Making columns runtime-configurable would put user data in control of compliance-gate semantics — the single-tenant reality gives no payoff for that risk. (Ponytail note: this is the checklist item where the "right" Kanban answer is the wrong engineering answer.)
  • "Fork" — already the de-facto state, and the right one: the funnels have different interaction needs (free drag vs. gated progression), different blast radii (a mis-dragged lead is trivial; a mis-advanced candidate violates a compliance gate), and different rhythms (deals close; candidates are processed).

The actual gaps are not architectural — they are missing signals: no time-in-stage on either board, a dead next_action_date, a write-only reheat_date, and a recruiting board with no affordances. Fix those in place (§7). If Ability Builders ever becomes a real tenant, its relationship funnel should be grown from the Facilities cadence/renewal surface (which already models cyclical nurture), not from the CRM Kanban — and that decision is blocked on the tenancy work in §5.4 regardless.


7. Prioritized recommendations

[P0 breaks/leaks]

  1. Harden PipelineBoard.move against transport failure — wrap the action call in try/catch/finally: decrement pending in finally, revert + toast in catch. Target: src/components/admin/PipelineBoard.tsx:54-74.
  2. Owner-gate markReferralPaid server-side (requireOwner or role check) to match its own contract and the UI. Target: src/server/actions/recruiting.ts:371-395.
  3. Owner-gate hireCandidate server-side to match the owner-only UI (or document that staff hires are intended and expose the form to staff). Target: src/server/actions/recruiting.ts:940-994, src/components/admin/CandidateActions.tsx:554.

[P1 goal-mismatch] 4. Add time-in-stage to both boards. Cheapest correct source: a stage_entered_at column set alongside every stage write (actions/crm.ts:171, actions/recruiting.ts:773, 0055:70) — or derive from the existing audit_log rows (both tables are audited, 0032:58-61, 0054:270) if no migration is wanted. Render "Nd in stage" on cards with a stale threshold. Targets: PipelineBoard.tsx, recruiting/page.tsx, one migration. 5. Make the recruiting board actionable: add the CRM board's per-card "Move to…" RowActions menu calling the existing moveApplicationStage (its PRC/consent error messages already exist, actions/recruiting.ts:779-786). No drag needed — the menu alone removes the board→detail round trip. Targets: src/app/admin/recruiting/page.tsx:274-301 (extract a small client component). 6. Resolve next_action_date: either write it (offer/interview actions are natural writers) and render it + owner_id on board cards, or drop it from BOARD_COLUMNS and the schema. Targets: services/recruiting.ts:41, actions/recruiting.ts:299-368, recruiting/page.tsx:283. 7. Surface reheat_date: include due-for-reheat lost leads in the Today's Follow-ups tab or a KPI (queries/pipeline.ts:769-841, crm/page.tsx:62) — or delete the field and its form input. A stored-but-never-read re-engagement date is the CRM's one cyclical affordance, and it's dead. 8. Fix the requisition filter: carry req through the board filter form as a hidden input and show an active-filter chip with a clear control. Target: src/app/admin/recruiting/page.tsx:193-230.

[P2 polish] 9. Add loading.tsx + error.tsx to the recruiting segment (mirror crm/loading.tsx, crm/error.tsx). Target: src/app/admin/recruiting/. 10. Per-column empty state on the recruiting board (mirror PipelineBoard.tsx:155-157). 11. Decide one responsive strategy for boards (CRM stacks, recruiting scrolls — §4); align whichever is chosen. 12. Consider the app's SlideOver shelf for card peek (consistency with Invoicing shelves) before full-page nav. Targets: both boards; reuse src/components/ui/SlideOver.tsx. 13. Pagination/virtualization for boards is not needed yet at one-company scale — note the ceiling (~few hundred open cards) and revisit only if hit. Same for WIP limits: a soft over-limit tint on column counts is one line when a limit is agreed (see §8.3). 14. Delete the empty junk directories src/app/admin/positions/[id] 2/ and src/app/admin/positions/new 2/ (Finder-copy artifacts; empty, harmless, confusing).


8. Open product questions (owner decisions, not code decisions)

  1. Is Ability Builders ever becoming a tenant of this app? Everything single-tenant-scoped (§5.3-4) is fine until the answer is yes; then domain→company mapping, per-company sequences/vocabulary, and the AB funnel model all become real work. The code today is Nightingale-only, top to bottom. - no
  2. If yes — should AB's BD funnel be cyclical/steady-state rather than a terminal pipeline? The evidence says model it on the Facilities relationship surface (last-touch + cadence + renewal alerts), not on crm_leads. Needs an owner call on what "won" even means for a referral-source relationship. n/a
  3. WIP limits for recruiting: is pipeline overload a real failure mode at current hiring volume, and if so, what's the limit per stage? (No data in the repo suggests a current problem; requisition headcount exists as a natural anchor, 0054:94-122.) - no
  4. Dual control: PRC verification and all six compliance checks are self-certifiable by any single recruiting admin — an explicitly accepted decision (actions/recruiting.ts:232-236). Given hires create payroll identities, does the owner want a second-approver rule now that staff roles exist? - use Philippine Regulatory Commission website for verification
  5. Should staff be able to hire and pay referral bonuses at all (relates to P0-2/P0-3): the UI says owner-only, the server says recruiting-domain. Which one is the policy? - hire and recommend bonus subject to owner approval

9. Implementation follow-up (2026-07-02, same day)

Owner answered §8 inline (no AB tenancy, no WIP limits, PRC checked on the official PRC portal, hire/referral payout = owner approval). Implemented accordingly:

  • Both boards now drag-and-drop. New src/components/admin/RecruitingBoard.tsx mirrors the BD board: native drag + per-card "Move to…" menu (keyboard/touch), optimistic move with per-card revert + toast, PRC gate pre-checked client-side with a clear message (DB trigger stays the authority). Wired in src/app/admin/recruiting/page.tsx; the old read-only inline columns are gone.
  • P0 fixes: PipelineBoard.move hardened with try/catch/finally (transport failure now reverts + toasts instead of leaving a phantom card and a stuck pending counter); hireCandidate and markReferralPaid are owner-gated server-side (src/server/actions/recruiting.ts).
  • Days-in-stage: migration 0092_stage_entered_at.sql adds stage_entered_at to crm_leads + candidate_applications, maintained by one DB trigger for every stage writer, backfilled from updated_at. Both boards render "Nd in stage", amber "— stale" at ≥14 days.
  • Next steps on cards: next_action_date is now written by createInterview (interview date) and createOffer (expiry/start date) and rendered on recruiting cards with owner and an overdue tint — no more dead column.
  • Reheat surfacing: listReheatDueLeads + a "Reheat leads" section in Today's Follow-ups (counted in the tab badge and the smart default tab), with a Dismiss action (clearLeadReheat).
  • Requisition filter: carried through the filter form as a hidden input, shown as a chip with a Clear link.
  • PRC portal: the verify card now shows the license number and links to https://online.prc.gov.ph/verification (§8.4 answer). Single-verifier model retained.
  • Polish: recruiting segment loading.tsx + error.tsx added; per-column empty states on the recruiting board; junk positions/[id] 2 + positions/new 2 dirs deleted.
  • Deliberately not done (per §8 answers): WIP limits, AB tenancy work, board pagination/virtualization, shelf-based card peek (§7.12 — open).

Runtime verification + follow-up (same day, PR #79)

PR #78 was verified in the running app (production build, real data, temporary staff login since deleted): the PRC gate blocked a drag with the exact toast and zero mutation; a non-gated drag persisted across reload and was restored; the 0092 trigger stamped the move in the DB. Two data findings from that run were fixed in migration 0093 + code:

  • Every application was Unassignedowner_id backfilled (created_by, else the company owner) and now defaults to the acting admin on create (createApplication, reviewAndAddInboxItem, reengageCandidate).
  • 0092's updated_at-based backfill flattened days-in-stage (a bulk score-recompute had touched every row) → stage_entered_at re-derived from the audit trail's true last stage-change per row, falling back to created_at. Note audit_log.action is uppercase ('UPDATE').