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:
- There is one tenant, not two. The schema is multi-tenant-ready (
company_idon 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" at0002_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.cominbox 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] - The BD schema is not
accounts / contacts / opportunities / activities / pipeline_stages. It iscrm_leads(account + opportunity + primary contact denormalized into one row) +crm_activities(src/db/migrations/0032_crm_leads.sql:13-50). There is nopipeline_stagestable anywhere — stages areCHECKconstraints in the DB (0032:17-18,0054:214-215) mirrored by hardcoded arrays insrc/lib/crm/pipeline.ts:10andsrc/lib/recruiting/pipeline.ts:15-25. [confirmed] - 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
| Area | Menu / route | Page components | Client components | Server actions / services | Supabase 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-168 | — | AdminNav, AdminShell | getAdminLayoutContext (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, EmptyState | src/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/export | crm/[id]/page.tsx, crm/new/page.tsx | LeadForm, 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_date — facilities/page.tsx:35,81), facilities/[id] Relationship + Cadence tabs ([id]/page.tsx:376-424, 225-305) | FacilityRelationship.tsx, EnrollSequenceButton, FacilityQuickShelf | logFacilityNote (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:4 | recruiting/page.tsx (8 tabs incl. Pipeline Board page.tsx:254-309), recruiting/[id]/page.tsx (Candidate 360), recruiting/new/page.tsx | Board is inline JSX in the page (no shared board component); CandidateActions.tsx (move stage / interview / offer / reject / hire), AddCandidateForm, RecruitingInbox, RecruitingTalentPool, RecruitingToday, RecruitingRequisitions, RecruitingAnalytics, RecruitingSequences | src/server/actions/recruiting.ts (17 actions), src/server/services/recruiting.ts, src/server/services/followUp.ts | recruitment_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/onboarding | onboarding/page.tsx | CandidateActions.HireForm | hireCandidate (actions/recruiting.ts:940-994) → public.hire_candidate RPC | contractors, 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:
- Load:
crm/page.tsx:46-49→listOpenLeads(svc, admin.companyId, {q, ownerId})→crm_leadsfilteredcompany_id+status='open'+deleted_at is null, orderedupdated_at desc(src/server/services/crm.ts:47-56). No pagination. [confirmed] - 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). - Move: native HTML5 drag (
onDropPipelineBoard.tsx:90-96) or per-card "Move to…" menu (110-117) — the menu is the touch/keyboard fallback. Optimistic: card state flips immediately (58), apendingref suppresses prop re-sync mid-flight (43-49). - Persist:
moveLeadStage(actions/crm.ts:153-183): gaterequireDomain('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), guardedUPDATE … eq(company_id).eq(status,'open')returning rows (169-178). 0 rows → "Lead not found or closed." - Refetch: success → toast +
router.refresh()(PipelineBoard.tsx:66-67) plus serverrevalidatePath(crm.ts:180-181). Server error (res.ok === false) → per-card revert to capturedfromStage+ danger toast (69-72). Concurrent moves of different cards are safe (per-card revert, not snapshot revert — comment at55-57). Duplicate cards impossible (single array map). [confirmed] - Break point — network failure mid-drag:
await moveLeadStage(...)atPipelineBoard.tsx:63has no try/catch. If the server-action fetch itself rejects (offline, deploy blip), the exception escapes the handler: no revert, no toast, andpending.currentis never decremented (64is after the await), so thepending === 0guard at47-49permanently 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 evenrouter.refresh()can't correct it until a full reload. [inferred from code — not executed] - Terminal transitions live on the detail page by design (
PipelineBoard.tsx:35): "Advance" one stage, "Mark won → client", "Mark lost" (LeadDetailActions.tsx:200-238).markLeadWonis claim-first and race-safe: guardedopen→wonUPDATE, 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 leavesconverted_atstamped on the reopened lead (cosmetic). Won leads convert tofacilities+ seededfacility_contacts(299-324; mapping inlib/crm/pipeline.ts:42-75). [confirmed] - Dead end —
reheat_date:markLeadLoststores an optional reheat date (crm.ts:365; UILeadDetailActions.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)
- Load:
recruiting/page.tsx:122-128→listBoardApplications(svc, admin.companyId, {q, ownerId, requisitionId})→candidate_applications+ joined candidate, filteredcompany_id+status='open', orderedupdated_at desc(services/recruiting.ts:44-64). Free-text search filters in JS after fetching all rows (59-62). No pagination. [confirmed] - 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). - 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 theMoveStageFormselect on the detail page (CandidateActions.tsx:124-151→moveApplicationStage,actions/recruiting.ts:755-790). Round trip to move one candidate: board → detail → select → submit → back. [confirmed] - 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. - 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 onlytabas a hidden field (recruiting/page.tsx:195) — submitting a search silently drops thereqfilter, and nothing on screen indicates a requisition filter is active. [confirmed] - Hire → onboarding handoff — Works, genuinely transactional:
hireCandidate(actions/recruiting.ts:940-994) callspublic.hire_candidate(SECURITY DEFINER, execute revoked from anon/authenticated —0055:116). In one transaction it: locks the applicationFOR UPDATE(0055:34-37), re-checks PRC + the six compliance passes (0055:48-67), flips the applicationhired/hired, auto-withdraws the candidate's other open applications (0055:69-75), createscontractors(statusinvited),contractor_201_details,contractor_invitations(0055:77-98), linksconverted_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/onboardingautomatically 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 signal | Present? | Evidence / notes |
|---|---|---|
| Opportunity value | ✅ card + per-column sum + header total | PipelineBoard.tsx:106,135; crm/page.tsx:179 |
| Expected placement start | ⚠️ stored, not on board | expected_close_date (0032:29, editable LeadForm) — never rendered on a card |
| Facility size/type | ❌ on leads | crm_leads has only city/state (0032:25-26); beds/type live on facilities, i.e. post-conversion |
| Stage | ✅ columns | PipelineBoard.tsx:78 |
| Days-in-stage / stale-deal flag | ❌ | No 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 + filter | PipelineBoard.tsx:141-143; crm/page.tsx:96-105 |
| Next action + due date | ✅ | next_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 contact | contact_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 / forecast | ❌ | Nothing; column sums are the only forecast proxy |
| Activity history | ✅ on detail | crm_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
wonthat converts the lead into afacilitiesrow 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 (0053job_signals.role_familyCHECK 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_dateauto-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, renderedfacilities/[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 (0053follow_up_enrollmentsCHECK). 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 signal | Present? | Evidence / notes |
|---|---|---|
| Time-in-stage / bottleneck visibility | ❌ | No 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 | ✅ card | role_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 stage | ❌ | Nothing anywhere; columns show raw counts only (recruiting/page.tsx:271) |
| Interview scheduling / next step + owner | ❌ on board | Interviews 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
| Check | BD board | Recruiting 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-25 ↔ 0054: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 persists | N/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 states | ✅ crm/loading.tsx (skeleton) + crm/error.tsx exist | ❌ recruiting 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)
- Money mutation not owner-gated despite its own comment.
markReferralPaidis documented "(owner-only — it's money out)" but is gated only byrequireDomain('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:250passescanPay={admin.role==='owner'}), so the server is weaker than the UI. [confirmed] hireCandidateserver action not owner-gated while the UI is.HireFormrenders 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]- Service-role bypass is the norm on these pages (
service.ts:6-15): every listed page/action's tenant isolation rests on hand-writtencompany_idfilters. 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. - 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. - Candidate PII sub-queries scoped transitively, not directly (
services/recruiting.ts:163-204): applications/assessments/documents/activities are fetched bycandidate_idalone after a company-scoped candidate fetch. Safe today; addcompany_idfor depth. - DSAR ledger retains cleartext subject email by accepted decision (
actions/recruiting.ts:1093-1103);application_rawdeliberately not audited to keep résumé PII out ofaudit_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 writesstage='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]
- Harden
PipelineBoard.moveagainst transport failure — wrap the action call in try/catch/finally: decrementpendinginfinally, revert + toast incatch. Target:src/components/admin/PipelineBoard.tsx:54-74. - Owner-gate
markReferralPaidserver-side (requireOwneror role check) to match its own contract and the UI. Target:src/server/actions/recruiting.ts:371-395. - Owner-gate
hireCandidateserver-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)
- 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
- 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 - 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
headcountexists as a natural anchor,0054:94-122.) - no - 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 - 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.tsxmirrors 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 insrc/app/admin/recruiting/page.tsx; the old read-only inline columns are gone. - P0 fixes:
PipelineBoard.movehardened with try/catch/finally (transport failure now reverts + toasts instead of leaving a phantom card and a stuck pending counter);hireCandidateandmarkReferralPaidare owner-gated server-side (src/server/actions/recruiting.ts). - Days-in-stage: migration
0092_stage_entered_at.sqladdsstage_entered_attocrm_leads+candidate_applications, maintained by one DB trigger for every stage writer, backfilled fromupdated_at. Both boards render "Nd in stage", amber "— stale" at ≥14 days. - Next steps on cards:
next_action_dateis now written bycreateInterview(interview date) andcreateOffer(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.tsxadded; per-column empty states on the recruiting board; junkpositions/[id] 2+positions/new 2dirs 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 Unassigned →
owner_idbackfilled (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_atre-derived from the audit trail's true last stage-change per row, falling back tocreated_at. Noteaudit_log.actionis uppercase ('UPDATE').