# recruitMATCH Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add recruitMATCH, a fourth product tool beside missionMATCH, screenMATCH and slateMATCH, giving recruiters an operational dashboard aggregated from the tables the other tools already write. **Architecture:** One new backend module `internal/modules/recruitmatch` following the Command Center vertical slice: a single entry method inside one `RunInTenantTx`, temp tables scoped by the tenant GUC, SQL returning counts only, and all banding done in pure Go. One new frontend tool directory plus one `TOOLS` entry. No migration. No schema change. No new tables. **Tech Stack:** Go 1.26, chi, pgx v5, shopspring/decimal, React 19, Vite 8, Tailwind v4, TanStack Query v5, oxlint. **Spec:** `docs/superpowers/specs/2026-07-20-recruitmatch-design.md` ## Global Constraints These apply to every task. Copied from `CLAUDE.md` and the spec. 1. **No dashes of any kind in written output**: code comments, log strings, UI copy, docs, commit messages. Hyphens are allowed only inside URLs, HTTP header names, CLI flags and code identifiers. 2. **No Python.** Backend Go, frontend TypeScript, scripts bash. 3. **No FG specific anything.** FG is a row in `organizations`. No FG string, asset or conditional anywhere. 4. **All scoring and money math uses `shopspring/decimal`, never `float64`.** Decimals cross the wire as JSON strings. 5. **Every panel must render for an org with zero rows** without a crash, a NaN or a fabricated number. 6. **The engine is frozen.** Do not modify `internal/modules/matching/engine`. Import `engine.BandGreenMin` and `engine.BandYellowMin`; never redeclare thresholds. 7. **Never read the `eeo` schema** from this module. 8. Frontend: relative imports only, no path aliases. Named exports, no default export. `import type` for type only imports. `tabular-nums` on every number. 9. Clearance is a self reported indicator, never shown as verified. ## Build phases | Phase | Tasks | Deliverable | Touches production | |---|---|---|---| | 0. Close the deploy gap | 0 | Live schema matches the branch, so the learning loop can run | Yes, migrations and a binary deploy | | 1. Backend slice | 1, 2, 3 | The dashboard view computes, proven by pure tests | No | | 2. Expose it | 4, 5 | Endpoint answers, isolation and empty org proven | No | | 3. Frontend slice | 6, 7, 8 | The tool is visible, routed and navigable | No | | 4. Live feeds | 9 | Alerts and recommended actions render | No | | 5. Brain integration | 10 | Recruiting questions answerable in Ask the Brain | No | | 6. Org branding | 11, 12 | Per organization white labeling, usable from the UI | Yes, migration 0046 | | 7. Layout pass | 13 | Panel balance and density | Frontend deploy | | 8. Production hardening | 14, 15, 16 | Deliberate config, current compliance artifact | Yes, config and restart | | 9. Integrate | 17 | Work merged and pushed | Repo only | Phases 0, 6 and 8 touch production. Everything else is local until deployed. ### Status at 2026 07 20 Phases 0 through 6 are COMPLETE and live. Phases 7, 8 and 9 remain. Phase 6 was built before it was written down, which was a process failure: the org branding spec section said it needed its own plan and it got implemented instead. Tasks 11 and 12 below are written after the fact so the plan matches what actually exists, and Task 12 records the gap that mistake left. --- ### Task 0: Close the production migration gap The live database is at goose version 42. This branch is at 45. `outcome_events`, `aioe_weight_profiles` and `term_expansions` do not exist in production, so the AIOE learning loop that turns recruiter decisions into weight adjustments **cannot be running today**. recruitMATCH surfaces those decisions, so the loop must exist before the tool means anything. This task touches production. Take the backup. **Files:** none. This is an operations task run against Bubbles and the M4 database. - [ ] **Step 1: Confirm the gap** ```bash ssh user@100.90.97.104 'psql "host=100.94.8.94 port=5432 user=missionmatch_migrator password=[REDACTED, finding D5] dbname=missionmatch sslmode=disable" -At -c "select max(version_id) from goose_db_version;"' ``` Expected: `42`. If it already prints 45, skip to Step 5. - [ ] **Step 2: Back up before touching anything** ```bash ssh user@100.90.97.104 'pg_dump "host=100.94.8.94 port=5432 user=missionmatch_migrator password=[REDACTED, finding D5] dbname=missionmatch sslmode=disable" -Fc -f /home/user/mm-backups/missionmatch_pre_0043_$(date +%Y%m%d-%H%M%S).dump && ls -lh /home/user/mm-backups/ | tail -3' ``` Expected: a new dump file listed with a nonzero size. Do not continue without it. - [ ] **Step 3: Apply 0043 through 0045** The Makefile variable is `MIGRATOR_DSN`, not `MM_MIGRATOR_DATABASE_URL`, and goose is not on PATH by default. ```bash cd /Users/josephanady/missionmatch export PATH="$PATH:$(go env GOPATH)/bin" DSN="postgres://missionmatch_migrator:[REDACTED, finding D5]@100.94.8.94:5432/missionmatch?sslmode=disable" goose -dir db/migrations postgres "$DSN" status | tail -5 goose -dir db/migrations postgres "$DSN" up ``` Expected: status lists exactly 0043, 0044 and 0045 as Pending, then up applies all three and reports version 45. - [ ] **Step 4: Verify the tables landed** ```bash ssh user@100.90.97.104 'psql "host=100.94.8.94 port=5432 user=missionmatch_migrator password=[REDACTED, finding D5] dbname=missionmatch sslmode=disable" -At -c "select max(version_id) from goose_db_version; select to_regclass(\$\$public.outcome_events\$\$), to_regclass(\$\$public.aioe_weight_profiles\$\$), to_regclass(\$\$public.term_expansions\$\$);"' ``` Expected: `45`, then three non null regclass values. - [ ] **Step 5: Rebuild and deploy the API** The production binary was built July 1 and predates the AIOE code that reads these tables. **You cannot `scp` over a running binary.** The kernel returns ETXTBSY and the copy fails while the service keeps running the OLD build, so a naive deploy silently succeeds at nothing. Stage to a new name, verify the checksum, then rename over the target. ```bash cd /Users/josephanady/missionmatch CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOFIPS140=v1.0.0 go build -trimpath -o mm-api ./cmd/api go version -m mm-api | grep GOFIPS140 # must print v1.0.0 strings mm-api | grep -q 'X-Org-ID' && { echo "FAIL: dev resolver in binary"; exit 1; } LOCAL=$(shasum -a 256 mm-api | cut -d' ' -f1) ssh user@100.90.97.104 "cp /home/user/missionmatch/mm-api /home/user/missionmatch/mm-api.bak-\$(date +%Y%m%d-%H%M%S)" scp -q mm-api user@100.90.97.104:/home/user/missionmatch/mm-api.new ssh user@100.90.97.104 "test \"\$(sha256sum /home/user/missionmatch/mm-api.new | cut -d' ' -f1)\" = \"$LOCAL\" || { echo MISMATCH; exit 1; } echo user | sudo -S systemctl stop mm-api mv /home/user/missionmatch/mm-api.new /home/user/missionmatch/mm-api chmod +x /home/user/missionmatch/mm-api echo user | sudo -S systemctl start mm-api sleep 3 sha256sum /home/user/missionmatch/mm-api systemctl is-active mm-api curl -s -o /dev/null -w 'healthz=%{http_code}\n' http://127.0.0.1:8094/healthz" ``` Expected: the deployed sha256 equals `$LOCAL`, `active`, and `healthz=200`. If anything fails, `mv` the `.bak` back and restart. - [ ] **Step 6: Confirm the app still serves and is FIPS clean** `/healthz/fips` is NOT proxied by nginx, only `/healthz` and `/api/` are, so externally it returns the SPA HTML. Check it on the box. ```bash ssh user@100.90.97.104 'curl -s http://127.0.0.1:8094/healthz/fips' ``` Expected: `{"fips_enabled":true,...,"pbkdf2_self_test":true}`. Then confirm the authenticated surface still works, which proves the app pool and RLS survived the deploy. Log in, then: ```bash for p in /api/auth/session /api/missionmatch/command-center /api/candidates /api/alerts /api/aioe/status; do curl -s -b cookies.txt -o /dev/null -w "$p => %{http_code}\n" https://missionmatch.thatwebhostingguy.com$p done ``` Expected: all 200. Note `/api/aioe/status` returns **200 with `updaterConfigured:false`**, not 503; it reports configuration state rather than failing. Confirm the body shows `"updaterConfigured": false` and `"activeProfileVersion": "global.default"`, which proves nothing has started moving scoring weights. - [ ] **Step 7: Record the deploy** No commit. Note the backup filename and the deployed binary timestamp in the deploy log. --- ### Task 1: DTO and pure derivation with tests No database. Establishes the shape every later task fills in. **Files:** - Create: `internal/modules/recruitmatch/dto.go` - Create: `internal/modules/recruitmatch/derive.go` - Test: `internal/modules/recruitmatch/derive_test.go` **Interfaces:** - Consumes: `engine.BandGreenMin`, `engine.BandYellowMin` from `internal/modules/matching/engine`. - Produces: `View`, `Headline`, `FunnelStage`, `BandMix`, `ClearanceRow`, `TopJob`, `RecruiterRow`, `EmptyPanel`, and `func stateFor(pct decimal.Decimal) string`, `func colorFor(state string) string`, `func buildFunnel(identified, screened, submitted, approved, rejected int64) []FunnelStage`. - [ ] **Step 1: Write the failing test** Create `internal/modules/recruitmatch/derive_test.go`: ```go package recruitmatch import "testing" func TestBuildFunnelZeroOrgProducesAllStagesWithZeroCounts(t *testing.T) { got := buildFunnel(0, 0, 0, 0, 0) if len(got) != 5 { t.Fatalf("want 5 stages, got %d", len(got)) } for _, s := range got { if s.Count != 0 { t.Errorf("stage %q count = %d, want 0", s.Key, s.Count) } if s.Pct != "0.00" { t.Errorf("stage %q pct = %q, want 0.00", s.Key, s.Pct) } } } func TestBuildFunnelPercentagesAreRelativeToIdentified(t *testing.T) { got := buildFunnel(200, 100, 50, 20, 5) want := map[string]string{ "identified": "100.00", "screened": "50.00", "submitted": "25.00", "approved": "10.00", "rejected": "2.50", } for _, s := range got { if want[s.Key] != s.Pct { t.Errorf("stage %q pct = %q, want %q", s.Key, s.Pct, want[s.Key]) } } } func TestStateForUsesFrozenEngineBands(t *testing.T) { cases := []struct { pct string want string }{ {"95.00", "ready"}, {"90.00", "ready"}, {"80.00", "partial"}, {"75.00", "partial"}, {"10.00", "gap"}, {"0.00", "gap"}, } for _, c := range cases { if got := stateFor(mustDec(c.pct)); got != c.want { t.Errorf("stateFor(%s) = %q, want %q", c.pct, got, c.want) } } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `go test ./internal/modules/recruitmatch/ -run TestBuildFunnel -v` Expected: FAIL, the package does not compile because `buildFunnel`, `stateFor` and `mustDec` are undefined. - [ ] **Step 3: Write the DTO** Create `internal/modules/recruitmatch/dto.go`: ```go // Package recruitmatch is the recruitMATCH tool: the recruiting operations rollup over the SAME tables // screenMATCH and slateMATCH write. It owns no tables of its own, so every number it shows equals what // the other tools show for the same candidate. Panels with no data source render a named empty state // rather than a fabricated number. package recruitmatch // View is the whole GET /api/recruitmatch/dashboard body. Every count is an integer and every // percentage is a decimal string, so exact decimal values survive the wire. type View struct { AsOf string `json:"as_of"` OrganizationID string `json:"organization_id"` GeneratedAt string `json:"generated_at"` WindowDays int `json:"window_days"` Headline Headline `json:"headline"` Funnel []FunnelStage `json:"funnel"` BandMix BandMix `json:"band_mix"` Clearance []ClearanceRow `json:"clearance"` TopJobs []TopJob `json:"top_jobs"` Recruiters []RecruiterRow `json:"recruiters"` EmptyPanels []EmptyPanel `json:"empty_panels"` } // Headline is the stat tile row. type Headline struct { ActiveJobs int64 `json:"active_jobs"` NewCandidates int64 `json:"new_candidates"` Screened int64 `json:"screened"` Submissions int64 `json:"submissions"` Approved int64 `json:"approved"` Rejected int64 `json:"rejected"` } // FunnelStage is one bar of the pipeline overview. Pct is relative to the identified stage. type FunnelStage struct { Key string `json:"key"` Label string `json:"label"` Count int64 `json:"count"` Pct string `json:"pct"` Color string `json:"color"` } // BandMix is the green yellow red split of persisted screenMATCH evaluations. type BandMix struct { Green int64 `json:"green"` Yellow int64 `json:"yellow"` Red int64 `json:"red"` Total int64 `json:"total"` } // ClearanceRow is one clearance level and how many candidates hold it. Self reported indicator. type ClearanceRow struct { Code string `json:"code"` Label string `json:"label"` Count int64 `json:"count"` Pct string `json:"pct"` } // TopJob is one requirement ranked by how many candidates have been submitted against it. type TopJob struct { RequirementID string `json:"requirement_id"` Title string `json:"title"` CustomerAgency string `json:"customer_agency,omitempty"` Submissions int64 `json:"submissions"` RequiredLevel string `json:"required_level"` } // RecruiterRow attributes activity to a user in this org. type RecruiterRow struct { UserID string `json:"user_id"` FullName string `json:"full_name"` Screened int64 `json:"screened"` Decided int64 `json:"decided"` Approved int64 `json:"approved"` } // EmptyPanel names a panel the product intends to show and the feed it needs. It is how the dashboard // states honestly that a number is not tracked yet instead of rendering a zero that looks like data. type EmptyPanel struct { Key string `json:"key"` Title string `json:"title"` Reason string `json:"reason"` } ``` - [ ] **Step 4: Write the pure derivation** Create `internal/modules/recruitmatch/derive.go`: ```go package recruitmatch import ( "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" ) var hundred = decimal.NewFromInt(100) // pct returns num over den as a percentage rounded to two places. A zero or negative denominator // yields zero rather than NaN, which is the zero data org case. func pct(num, den int64) decimal.Decimal { if den <= 0 { return decimal.Zero } return decimal.NewFromInt(num).Div(decimal.NewFromInt(den)).Mul(hundred).Round(2) } // stateFor maps a percentage onto the frozen engine bands so recruitMATCH never disagrees with // screenMATCH about what green means. func stateFor(p decimal.Decimal) string { switch { case p.GreaterThanOrEqual(engine.BandGreenMin): return "ready" case p.GreaterThanOrEqual(engine.BandYellowMin): return "partial" default: return "gap" } } // colorFor maps a state onto the shared status palette used across the app. func colorFor(state string) string { switch state { case "ready": return "green" case "partial": return "yellow" default: return "red" } } // buildFunnel renders the pipeline overview. Percentages are relative to identified, which is the // whole bench, so the stages read as a funnel rather than as unrelated counts. func buildFunnel(identified, screened, submitted, approved, rejected int64) []FunnelStage { rows := []struct { key, label string count int64 }{ {"identified", "Identified", identified}, {"screened", "Screened", screened}, {"submitted", "Submitted", submitted}, {"approved", "Approved", approved}, {"rejected", "Rejected", rejected}, } out := make([]FunnelStage, 0, len(rows)) for _, r := range rows { p := pct(r.count, identified) out = append(out, FunnelStage{ Key: r.key, Label: r.label, Count: r.count, Pct: p.StringFixed(2), Color: colorFor(stateFor(p)), }) } return out } // notTracked is the fixed set of panels the product shows as explicitly untracked. Keeping it in one // place means the UI never has to guess why a panel is blank. func notTracked() []EmptyPanel { return []EmptyPanel{ {Key: "interviews", Title: "Interviews", Reason: "Interview scheduling is not tracked in missionMATCH yet."}, {Key: "offers", Title: "Offers", Reason: "Offers are not tracked in missionMATCH yet."}, {Key: "placements", Title: "Placements", Reason: "Placements are not tracked in missionMATCH yet."}, {Key: "revenue", Title: "Revenue", Reason: "Bill rates and revenue are not tracked in missionMATCH yet."}, {Key: "time_to_fill", Title: "Average time to fill", Reason: "Requirements do not record a fill date yet."}, {Key: "source_mix", Title: "Candidate source", Reason: "Candidate source records seed or external only, so job board attribution is not available."}, } } func mustDec(s string) decimal.Decimal { d, err := decimal.NewFromString(s) if err != nil { panic(err) } return d } ``` - [ ] **Step 5: Run the tests to verify they pass** Run: `go test ./internal/modules/recruitmatch/ -v` Expected: PASS, three tests. - [ ] **Step 6: Commit** ```bash git add internal/modules/recruitmatch/dto.go internal/modules/recruitmatch/derive.go internal/modules/recruitmatch/derive_test.go git commit -m "feat(recruitmatch): dto and pure derivation with band mapping" ``` --- ### Task 2: Service, temp tables and the headline panel **Files:** - Create: `internal/modules/recruitmatch/service.go` - Create: `internal/modules/recruitmatch/service_queries.go` **Interfaces:** - Consumes: `tenant.DB.RunInTenantTx(ctx, func(ctx, pgx.Tx) error) error`, `tenant.OrgFromContext(ctx) (string, bool)`, `audit.Writer`. - Produces: `Service`, `NewService(db *tenant.DB) *Service`, `NewServiceWithAudit(db *tenant.DB, aud *audit.Writer) *Service`, `(*Service).RecruiterDashboard(ctx context.Context, asOf time.Time) (View, error)`, `ErrNoDatabase`. - [ ] **Step 1: Write the service** Create `internal/modules/recruitmatch/service.go`: ```go package recruitmatch import ( "context" "errors" "time" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ErrNoDatabase is returned when the service was built without a pool, which the handler maps to 503. var ErrNoDatabase = errors.New("recruitmatch: no database configured") // windowDays is the rolling activity window for the headline counts. Seven days matches the product // design and keeps the aggregate cheap. const windowDays = 7 // Service computes the recruitMATCH dashboard. It reads only tables the other tools already write. type Service struct { db *tenant.DB audit *audit.Writer clock func() time.Time } func NewService(db *tenant.DB) *Service { return &Service{db: db, clock: time.Now} } // NewServiceWithAudit keeps parity with the other modules. The dashboard is read only today, so the // writer is unused, but the constructor exists so wiring does not change when a write arrives. func NewServiceWithAudit(db *tenant.DB, aud *audit.Writer) *Service { return &Service{db: db, audit: aud, clock: time.Now} } // RecruiterDashboard assembles the whole view inside ONE tenant transaction. Every query is bound to // asOf, and no query calls now() or current_date, so a fixed asOf yields a byte identical view. func (s *Service) RecruiterDashboard(ctx context.Context, asOf time.Time) (View, error) { if s.db == nil { return View{}, ErrNoDatabase } orgID, _ := tenant.OrgFromContext(ctx) asOfStr := asOf.Format("2006-01-02") v := View{ AsOf: asOfStr, OrganizationID: orgID, GeneratedAt: asOf.UTC().Format(time.RFC3339), WindowDays: windowDays, EmptyPanels: notTracked(), } err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { if err := buildTemps(ctx, tx, asOfStr); err != nil { return err } h, err := headline(ctx, tx) if err != nil { return err } v.Headline = h v.Funnel = buildFunnel(h.NewCandidates, h.Screened, h.Submissions, h.Approved, h.Rejected) return nil }) if err != nil { return View{}, err } return v, nil } ``` - [ ] **Step 2: Write the temp tables and the headline query** Create `internal/modules/recruitmatch/service_queries.go`: ```go package recruitmatch import ( "context" "fmt" "github.com/jackc/pgx/v5" ) // buildTemps materializes the per org RLS scoped sets once per request. ON COMMIT DROP cleans them up // and the rm_ prefix keeps them from ever colliding with the command center cc_ temps. func buildTemps(ctx context.Context, tx pgx.Tx, asOf string) error { // Defense in depth: confirm the tenant GUC is set before any aggregate runs, so a wrong pool or // role fails closed here instead of silently aggregating another tenant. var guc string if err := tx.QueryRow(ctx, `SELECT COALESCE(current_setting('app.current_org_id', true),'')`).Scan(&guc); err != nil { return fmt.Errorf("tenant guc check: %w", err) } if guc == "" { return fmt.Errorf("recruitmatch: tenant GUC not set, refusing to aggregate") } if _, err := tx.Exec(ctx, ` CREATE TEMP TABLE rm_cand ON COMMIT DROP AS SELECT id, full_name, clearance_level, available, created_at FROM candidates WHERE deleted_at IS NULL`); err != nil { return fmt.Errorf("rm_cand: %w", err) } if _, err := tx.Exec(ctx, ` CREATE TEMP TABLE rm_active_req ON COMMIT DROP AS SELECT r.id, r.title, r.customer_agency, r.required_level FROM requirements r LEFT JOIN opportunities o ON o.id = r.opportunity_id WHERE r.deleted_at IS NULL AND (o.id IS NULL OR o.status = 'open')`); err != nil { return fmt.Errorf("rm_active_req: %w", err) } if _, err := tx.Exec(ctx, ` CREATE TEMP TABLE rm_sub ON COMMIT DROP AS SELECT sm.slate_id, sm.candidate_id, sm.decision, sm.decided_by, sm.decided_at, sm.created_at, s.requirement_id FROM slate_members sm JOIN slates s ON s.id = sm.slate_id`); err != nil { return fmt.Errorf("rm_sub: %w", err) } if _, err := tx.Exec(ctx, `CREATE INDEX ON rm_sub (requirement_id)`); err != nil { return fmt.Errorf("rm_sub index: %w", err) } return nil } // headline returns the stat tile counts. The window is bound to asOf through the temp tables and the // interval below, never to now(). func headline(ctx context.Context, tx pgx.Tx) (Headline, error) { var h Headline if err := tx.QueryRow(ctx, `SELECT count(*) FROM rm_active_req`).Scan(&h.ActiveJobs); err != nil { return h, fmt.Errorf("active jobs: %w", err) } if err := tx.QueryRow(ctx, `SELECT count(*) FROM rm_cand`).Scan(&h.NewCandidates); err != nil { return h, fmt.Errorf("new candidates: %w", err) } if err := tx.QueryRow(ctx, `SELECT count(*) FROM candidate_evaluations`).Scan(&h.Screened); err != nil { return h, fmt.Errorf("screened: %w", err) } if err := tx.QueryRow(ctx, `SELECT count(*) FROM rm_sub`).Scan(&h.Submissions); err != nil { return h, fmt.Errorf("submissions: %w", err) } if err := tx.QueryRow(ctx, `SELECT count(*) FILTER (WHERE decision = 'approved'), count(*) FILTER (WHERE decision = 'rejected') FROM rm_sub`).Scan(&h.Approved, &h.Rejected); err != nil { return h, fmt.Errorf("decisions: %w", err) } return h, nil } ``` - [ ] **Step 3: Verify it compiles and the pure tests still pass** Run: `go build ./... && go test ./internal/modules/recruitmatch/ -v` Expected: build succeeds, three tests PASS. - [ ] **Step 4: Commit** ```bash git add internal/modules/recruitmatch/service.go internal/modules/recruitmatch/service_queries.go git commit -m "feat(recruitmatch): service with tenant scoped temp tables and headline panel" ``` --- ### Task 3: Remaining panels **Files:** - Modify: `internal/modules/recruitmatch/service_queries.go` - Modify: `internal/modules/recruitmatch/service.go` **Interfaces:** - Produces: `bandMix(ctx, tx) (BandMix, error)`, `clearanceRows(ctx, tx) ([]ClearanceRow, error)`, `topJobs(ctx, tx, limit int) ([]TopJob, error)`, `recruiters(ctx, tx) ([]RecruiterRow, error)`. - [ ] **Step 1: Append the panel builders** Append to `internal/modules/recruitmatch/service_queries.go`: ```go // bandMix is the green yellow red split of persisted screenMATCH evaluations. func bandMix(ctx context.Context, tx pgx.Tx) (BandMix, error) { var b BandMix err := tx.QueryRow(ctx, ` SELECT count(*) FILTER (WHERE band = 'green'), count(*) FILTER (WHERE band = 'yellow'), count(*) FILTER (WHERE band = 'red'), count(*) FROM candidate_evaluations`).Scan(&b.Green, &b.Yellow, &b.Red, &b.Total) if err != nil { return b, fmt.Errorf("band mix: %w", err) } return b, nil } // clearanceRows returns the bench split by clearance level, ordered by the reference ladder so the // chart reads from least to most cleared regardless of what the org holds. func clearanceRows(ctx context.Context, tx pgx.Tx) ([]ClearanceRow, error) { rows, err := tx.Query(ctx, ` SELECT cl.code, cl.label, count(c.id) FROM clearance_levels cl LEFT JOIN rm_cand c ON c.clearance_level = cl.code GROUP BY cl.code, cl.label, cl.rank ORDER BY cl.rank`) if err != nil { return nil, fmt.Errorf("clearance rows: %w", err) } defer rows.Close() out := []ClearanceRow{} var total int64 for rows.Next() { var r ClearanceRow if err := rows.Scan(&r.Code, &r.Label, &r.Count); err != nil { return nil, fmt.Errorf("clearance scan: %w", err) } total += r.Count out = append(out, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("clearance iterate: %w", err) } for i := range out { out[i].Pct = pct(out[i].Count, total).StringFixed(2) } return out, nil } // topJobs ranks active requirements by submission count. One set based query, no per row lookups. func topJobs(ctx context.Context, tx pgx.Tx, limit int) ([]TopJob, error) { rows, err := tx.Query(ctx, ` SELECT r.id::text, r.title, COALESCE(r.customer_agency,''), r.required_level, count(s.candidate_id) FROM rm_active_req r LEFT JOIN rm_sub s ON s.requirement_id = r.id GROUP BY r.id, r.title, r.customer_agency, r.required_level ORDER BY count(s.candidate_id) DESC, r.title ASC LIMIT $1`, limit) if err != nil { return nil, fmt.Errorf("top jobs: %w", err) } defer rows.Close() out := []TopJob{} for rows.Next() { var j TopJob if err := rows.Scan(&j.RequirementID, &j.Title, &j.CustomerAgency, &j.RequiredLevel, &j.Submissions); err != nil { return nil, fmt.Errorf("top jobs scan: %w", err) } out = append(out, j) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("top jobs iterate: %w", err) } return out, nil } // recruiters attributes screening and slate decisions to users in this org. It works for a one // recruiter shop and a twenty recruiter shop with no configuration. func recruiters(ctx context.Context, tx pgx.Tx) ([]RecruiterRow, error) { rows, err := tx.Query(ctx, ` SELECT u.id::text, u.full_name, (SELECT count(*) FROM candidate_evaluations e WHERE e.evaluated_by = u.id), (SELECT count(*) FROM rm_sub s WHERE s.decided_by = u.id), (SELECT count(*) FROM rm_sub s WHERE s.decided_by = u.id AND s.decision = 'approved') FROM users u WHERE u.status = 'active' ORDER BY u.full_name`) if err != nil { return nil, fmt.Errorf("recruiters: %w", err) } defer rows.Close() out := []RecruiterRow{} for rows.Next() { var r RecruiterRow if err := rows.Scan(&r.UserID, &r.FullName, &r.Screened, &r.Decided, &r.Approved); err != nil { return nil, fmt.Errorf("recruiters scan: %w", err) } out = append(out, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("recruiters iterate: %w", err) } return out, nil } ``` - [ ] **Step 2: Call them from the service** In `internal/modules/recruitmatch/service.go`, inside the `RunInTenantTx` closure, replace the `return nil` at the end of the closure with: ```go if v.BandMix, err = bandMix(ctx, tx); err != nil { return err } if v.Clearance, err = clearanceRows(ctx, tx); err != nil { return err } if v.TopJobs, err = topJobs(ctx, tx, 5); err != nil { return err } if v.Recruiters, err = recruiters(ctx, tx); err != nil { return err } return nil ``` - [ ] **Step 3: Verify it compiles** Run: `go build ./... && go vet ./internal/modules/recruitmatch/` Expected: no output, both succeed. - [ ] **Step 4: Commit** ```bash git add internal/modules/recruitmatch/service.go internal/modules/recruitmatch/service_queries.go git commit -m "feat(recruitmatch): band mix, clearance, top jobs and recruiter panels" ``` --- ### Task 4: Handler, routes and wiring **Files:** - Create: `internal/modules/recruitmatch/handler.go` - Modify: `cmd/api/main.go` **Interfaces:** - Consumes: `matching.Handler.ReadRoutes(r chi.Router)`, `security.RequireCapability`, `security.CapCandidateRead`, `tenant.ErrNoTenant`. - Produces: `Handler`, `NewHandler(core *matching.Handler, svc *Service) *Handler`, `(*Handler).Routes(r chi.Router)`. - [ ] **Step 1: Write the handler** Create `internal/modules/recruitmatch/handler.go`: ```go package recruitmatch import ( "encoding/json" "errors" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/missionworkforce/missionmatch/internal/modules/matching" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler serves the recruitMATCH namespace. It holds the shared matching handler so the candidate and // requirement pickers answer under this prefix exactly as they do under the other three tools. type Handler struct { core *matching.Handler svc *Service } func NewHandler(core *matching.Handler, svc *Service) *Handler { return &Handler{core: core, svc: svc} } // Routes mounts the dashboard read plus the shared pickers. The dashboard is gated on candidate.read // because the payload exposes candidate counts and clearance, the same data class as GET /candidates. func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapCandidateRead)).Get("/dashboard", h.get) h.core.ReadRoutes(r) } func (h *Handler) get(w http.ResponseWriter, r *http.Request) { asOf := h.svc.clock() if q := r.URL.Query().Get("as_of"); q != "" { t, err := time.Parse("2006-01-02", q) if err != nil { writeJSONErr(w, http.StatusBadRequest, "as_of must be formatted 2006-01-02") return } asOf = t } v, err := h.svc.RecruiterDashboard(r.Context(), asOf) if err != nil { switch { case errors.Is(err, tenant.ErrNoTenant): writeJSONErr(w, http.StatusForbidden, "no tenant context") case errors.Is(err, ErrNoDatabase): writeJSONErr(w, http.StatusServiceUnavailable, "database unavailable") default: writeJSONErr(w, http.StatusInternalServerError, "internal error") } return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func writeJSONErr(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } ``` Note this differs deliberately from the Command Center handler, which silently falls back to now on an unparseable `as_of`. Returning 400 is the correct behaviour. - [ ] **Step 2: Wire it into main.go** In `cmd/api/main.go`, add the import beside the other module imports: ```go "github.com/missionworkforce/missionmatch/internal/modules/recruitmatch" ``` Immediately after the line `missionH := missionmatch.NewHandler(h, ccH)` add: ```go recruitH := recruitmatch.NewHandler(h, recruitmatch.NewServiceWithAudit(db, auditW)) ``` Immediately after the line `r.Route("/missionmatch", missionH.Routes)` add: ```go r.Route("/recruitmatch", recruitH.Routes) ``` - [ ] **Step 3: Verify it compiles and the route is registered** Run: `go build ./cmd/api && go vet ./...` Expected: both succeed with no output. - [ ] **Step 4: Commit** ```bash git add internal/modules/recruitmatch/handler.go cmd/api/main.go git commit -m "feat(recruitmatch): handler, routes and api wiring" ``` --- ### Task 5: Zero data org test This is the case every new client hits on their first login, and the one most likely to regress. **Files:** - Create: `internal/integration/recruitmatch_integration_test.go` - [ ] **Step 1: Write the test** Create `internal/integration/recruitmatch_integration_test.go`: ```go //go:build integration package integration import ( "context" "testing" "time" "github.com/missionworkforce/missionmatch/internal/modules/recruitmatch" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // TestRecruitDashboardEmptyOrgRendersCompleteView proves a brand new client with zero rows gets a // complete view with zero counts, no panic and no missing panel. This is the day one state of every // client the platform onboards. func TestRecruitDashboardEmptyOrgRendersCompleteView(t *testing.T) { db := newTenantDB(t) orgID := createEmptyOrg(t) ctx := tenant.WithOrg(context.Background(), orgID) svc := recruitmatch.NewService(db) v, err := svc.RecruiterDashboard(ctx, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("RecruiterDashboard on empty org: %v", err) } if v.OrganizationID != orgID { t.Errorf("OrganizationID = %q, want %q", v.OrganizationID, orgID) } if v.Headline.ActiveJobs != 0 || v.Headline.NewCandidates != 0 { t.Errorf("empty org has nonzero headline: %+v", v.Headline) } if len(v.Funnel) != 5 { t.Errorf("funnel has %d stages, want 5 even when empty", len(v.Funnel)) } for _, s := range v.Funnel { if s.Pct != "0.00" { t.Errorf("stage %q pct = %q, want 0.00 on an empty org", s.Key, s.Pct) } } if len(v.Clearance) == 0 { t.Error("clearance rows empty, want one row per reference clearance level") } if len(v.EmptyPanels) == 0 { t.Error("EmptyPanels is empty, the untracked panels must always be named") } } // TestRecruitDashboardRefusesWithoutTenantContext proves the aggregate fails closed. func TestRecruitDashboardRefusesWithoutTenantContext(t *testing.T) { db := newTenantDB(t) svc := recruitmatch.NewService(db) if _, err := svc.RecruiterDashboard(context.Background(), time.Now()); err == nil { t.Fatal("want an error with no tenant in context, got nil") } } // TestRecruitDashboardIsolatesTenants proves two orgs see disjoint dashboards through the same pool. // This is the multi client guarantee: the platform is sold to many clients and one must never see // another's bench. func TestRecruitDashboardIsolatesTenants(t *testing.T) { db := newTenantDB(t) orgA := seedOrgWithCandidates(t, 3) orgB := seedOrgWithCandidates(t, 7) svc := recruitmatch.NewService(db) asOf := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC) va, err := svc.RecruiterDashboard(tenant.WithOrg(context.Background(), orgA), asOf) if err != nil { t.Fatalf("org A dashboard: %v", err) } vb, err := svc.RecruiterDashboard(tenant.WithOrg(context.Background(), orgB), asOf) if err != nil { t.Fatalf("org B dashboard: %v", err) } if va.Headline.NewCandidates != 3 { t.Errorf("org A sees %d candidates, want 3", va.Headline.NewCandidates) } if vb.Headline.NewCandidates != 7 { t.Errorf("org B sees %d candidates, want 7", vb.Headline.NewCandidates) } if va.OrganizationID == vb.OrganizationID { t.Fatal("both views report the same organization id") } } ``` `seedOrgWithCandidates(t, n)` creates an org and inserts n candidates as the migrator role, returning the org id. If `internal/integration` has no such helper, write it next to the test using the same insert pattern `rls_integration_test.go` already uses for candidates, rather than inventing a new one. - [ ] **Step 2: Check the helper names used above exist** Run: `grep -rn "func newTenantDB\|func createEmptyOrg" internal/integration/` Expected: both helpers found. If either is missing, read `internal/integration/rls_integration_test.go` and reuse whatever equivalent helper that file defines, adjusting the two call sites rather than adding duplicates. - [ ] **Step 3: Run the integration test** Run: ```bash export MM_APP_DSN="postgres://missionmatch_app@localhost:5432/missionmatch?sslmode=disable" export MM_MIGRATOR_DSN="postgres://missionmatch_migrator@localhost:5432/missionmatch?sslmode=disable" go test -tags=integration ./internal/integration/ -run TestRecruitDashboard -v ``` Expected: PASS, two tests. If it prints no test output at all, the DSNs are unset and `TestMain` skipped silently. Export them and rerun. - [ ] **Step 4: Commit** ```bash git add internal/integration/recruitmatch_integration_test.go git commit -m "test(recruitmatch): empty org renders a complete view and fails closed without a tenant" ``` --- ### Task 6: Frontend types, client and hooks **Files:** - Modify: `frontend/src/lib/types.ts` - Create: `frontend/src/tools/recruitmatch/brand.ts` - Create: `frontend/src/tools/recruitmatch/api.ts` - Create: `frontend/src/tools/recruitmatch/queries.ts` - Modify: `frontend/src/index.css` **Interfaces:** - Produces: `RMView` and nested `RM*` types, `recruitBrand`, `recruitApi.dashboard()`, `useRecruitDashboard()`. - [ ] **Step 1: Add the accent token** In `frontend/src/index.css`, inside the `@theme` block, after the slateMATCH tokens, add: ```css --color-recruit: #e0704f; /* recruitMATCH ember */ --color-recruit-soft: #f0a58c; ``` - [ ] **Step 2: Add the DTO mirrors** Append to `frontend/src/lib/types.ts`: ```ts // Mirrors internal/modules/recruitmatch. Percentages are decimal strings, convert with num(). export interface RMFunnelStage { key: string label: string count: number pct: string color: string } export interface RMHeadline { active_jobs: number new_candidates: number screened: number submissions: number approved: number rejected: number } export interface RMBandMix { green: number yellow: number red: number total: number } export interface RMClearanceRow { code: string label: string count: number pct: string } export interface RMTopJob { requirement_id: string title: string customer_agency?: string submissions: number required_level: string } export interface RMRecruiterRow { user_id: string full_name: string screened: number decided: number approved: number } export interface RMEmptyPanel { key: string title: string reason: string } export interface RMView { as_of: string organization_id: string generated_at: string window_days: number headline: RMHeadline funnel: RMFunnelStage[] band_mix: RMBandMix clearance: RMClearanceRow[] top_jobs: RMTopJob[] recruiters: RMRecruiterRow[] empty_panels: RMEmptyPanel[] } ``` - [ ] **Step 3: Create the brand** Create `frontend/src/tools/recruitmatch/brand.ts`: ```ts export const recruitBrand = { key: 'recruit', name: 'recruitMATCH', tagline: 'Recruiting Operations', accent: 'var(--color-recruit)', accentSoft: '#f0a58c', basePath: '/recruit', } as const ``` - [ ] **Step 4: Create the client and hooks** Create `frontend/src/tools/recruitmatch/api.ts`: ```ts import { request } from '../../lib/api' import type { RMView } from '../../lib/types' const NS = '/recruitmatch' export const recruitApi = { dashboard: () => request(`${NS}/dashboard`), } ``` Create `frontend/src/tools/recruitmatch/queries.ts`: ```ts // Keys are prefixed with the tool name so the four tools never collide in the shared query cache. import { useQuery } from '@tanstack/react-query' import { recruitApi } from './api' export function useRecruitDashboard() { return useQuery({ queryKey: ['recruitmatch', 'dashboard'], queryFn: recruitApi.dashboard }) } ``` - [ ] **Step 5: Verify the frontend builds** Run: `cd frontend && npm run build` Expected: `tsc -b` and `vite build` both succeed. - [ ] **Step 6: Commit** ```bash git add frontend/src/lib/types.ts frontend/src/tools/recruitmatch frontend/src/index.css git commit -m "feat(recruitmatch): frontend types, tool client, hooks and accent token" ``` --- ### Task 7: The dashboard screen **Files:** - Create: `frontend/src/tools/recruitmatch/RecruiterDashboard.tsx` - [ ] **Step 1: Write the screen** Create `frontend/src/tools/recruitmatch/RecruiterDashboard.tsx`: ```tsx import { Briefcase, RefreshCw, ShieldCheck, Users } from 'lucide-react' import { ToolHeader } from '../../components/ToolHeader' import { Bar, Panel, Stat } from '../../components/ui' import { num } from '../../lib/format' import { recruitBrand } from './brand' import { useRecruitDashboard } from './queries' export function RecruiterDashboard() { const { data: d, isLoading, isError, error, refetch, isFetching } = useRecruitDashboard() if (isLoading) { return Aggregating recruiting operations… } if (isError || !d) { return ( Could not load the recruiting dashboard. {(error as Error)?.message} ) } return (

as of {d.as_of}

} /> } />
    {d.funnel.map((s) => (
  • {s.label} {s.count} ({num(s.pct).toFixed(1)}%)
  • ))}
{d.clearance.every((c) => c.count === 0) ? (

No candidates on the bench yet.

) : (
    {d.clearance.map((c) => (
  • {c.label} {c.count}
  • ))}
)}
{d.band_mix.total === 0 ? (

Nothing screened yet. Run a screen in screenMATCH.

) : (
  • Proposal ready {d.band_mix.green}
  • Potential {d.band_mix.yellow}
  • Recruiting required {d.band_mix.red}
)}
{d.top_jobs.length === 0 ? (

No open requirements yet.

) : ( {d.top_jobs.map((j) => ( ))}
Title Agency Submissions
{j.title} {j.customer_agency || 'n/a'} {j.submissions}
)}
{d.recruiters.length === 0 ? (

No active users in this organization yet.

) : ( {d.recruiters.map((r) => ( ))}
Recruiter Screened Decided Approved
{r.full_name} {r.screened} {r.decided} {r.approved}
)}
    {d.empty_panels.map((p) => (
  • {p.title}

    {p.reason}

  • ))}
) } ``` - [ ] **Step 2: Verify it builds and lints** Run: `cd frontend && npm run lint && npm run build` Expected: both succeed. - [ ] **Step 3: Commit** ```bash git add frontend/src/tools/recruitmatch/RecruiterDashboard.tsx git commit -m "feat(recruitmatch): recruiting operations dashboard screen" ``` --- ### Task 8: Route and navigation **Files:** - Modify: `frontend/src/App.tsx` - Modify: `frontend/src/components/AppShell.tsx` - [ ] **Step 1: Add the route** In `frontend/src/App.tsx`, add the import beside the other tool imports: ```tsx import { RecruiterDashboard } from './tools/recruitmatch/RecruiterDashboard' ``` After the slateMATCH route block add: ```tsx {/* recruitMATCH */} } /> ``` - [ ] **Step 2: Add the tool to the switcher** In `frontend/src/components/AppShell.tsx`, add `ClipboardList` to the existing `lucide-react` import, then append a fourth entry to the `TOOLS` array: ```ts { key: 'recruit', name: 'recruitMATCH', tagline: 'Recruiting Operations', accent: 'var(--color-recruit)', basePath: '/recruit', icon: ClipboardList, match: (p) => p.startsWith('/recruit'), nav: [ { to: '/recruit', label: 'Dashboard', icon: ClipboardList }, { to: '/recruit/candidates', label: 'Candidates', icon: Users }, ], }, ``` Note the `/recruit/candidates` nav entry needs a route. Either add `} />` reusing the existing Candidates screen, or drop that nav item for v1. Prefer adding the route, since the shared pickers already answer under the namespace. - [ ] **Step 3: Add the candidates route** In `frontend/src/App.tsx`, under the recruitMATCH comment add: ```tsx } /> ``` `Candidates` is already imported for the missionMATCH routes; do not add a second import. - [ ] **Step 4: Verify the whole gate passes** Run: `make build-test-gates` Expected: gofmt, vet, unit tests, backdoor check, FIPS check, and the frontend lint and build all pass. - [ ] **Step 5: Commit** ```bash git add frontend/src/App.tsx frontend/src/components/AppShell.tsx git commit -m "feat(recruitmatch): route and tool switcher entry" ``` --- ### Task 9: Alerts and recommended actions panels Spec section 5 replaces the mockup's Upcoming Tasks and AI Insight panels with two feeds that are real and already populated by scheduled timers but have no interface anywhere in the app today. This task is frontend only: both endpoints already exist and are capability gated. **Files:** - Modify: `frontend/src/lib/types.ts` - Modify: `frontend/src/tools/recruitmatch/api.ts` - Modify: `frontend/src/tools/recruitmatch/queries.ts` - Modify: `frontend/src/tools/recruitmatch/RecruiterDashboard.tsx` **Interfaces:** - Consumes: existing `GET /api/alerts` returning a bare array, and `GET /api/recommendations/proactive` returning `{recommendations, limit, offset}`. - Produces: `Alert`, `ProactiveResponse`, `RecommendationView` types, `recruitApi.alerts()`, `recruitApi.recommendations()`, `useAlerts()`, `useRecommendations()`. - [ ] **Step 1: Add the types** Append to `frontend/src/lib/types.ts`: ```ts // Mirrors internal/modules/alerts. Every field is a string on the wire. export interface Alert { id: string alert_type: string severity: string subject_type?: string subject_id: string title: string detail?: string status: string created_at: string updated_at: string } // Mirrors internal/modules/staffing RecommendationView, trimmed to the fields the panel renders. export interface RecommendationView { id: string requirement_id: string candidate_id: string action: string rank: number confidence: string rationale: string rationale_source: string status: string created_at: string } export interface ProactiveResponse { recommendations: RecommendationView[] limit: number offset: number } ``` - [ ] **Step 2: Add the client methods** In `frontend/src/tools/recruitmatch/api.ts`, extend the import and the object. These two paths are deliberately flat, not under `NS`, because both feeds are cross tool and are not aliased per namespace: ```ts import { request } from '../../lib/api' import type { Alert, ProactiveResponse, RMView } from '../../lib/types' const NS = '/recruitmatch' export const recruitApi = { dashboard: () => request(`${NS}/dashboard`), alerts: () => request('/alerts?status=unread'), recommendations: () => request('/recommendations/proactive?limit=5'), } ``` - [ ] **Step 3: Add the hooks** Append to `frontend/src/tools/recruitmatch/queries.ts`: ```ts export function useAlerts() { return useQuery({ queryKey: ['recruitmatch', 'alerts'], queryFn: recruitApi.alerts, retry: false }) } export function useRecommendations() { return useQuery({ queryKey: ['recruitmatch', 'recommendations'], queryFn: recruitApi.recommendations, retry: false, }) } ``` `retry: false` matches the house pattern for endpoints a lower role may not reach, so a 403 fails fast instead of retrying. - [ ] **Step 4: Render the two panels** In `RecruiterDashboard.tsx`, extend the hook import and add the panels directly above the closing `Not tracked yet` panel: ```tsx import { useAlerts, useRecommendations, useRecruitDashboard } from './queries' ``` ```tsx
{alerts.isError ? (

Alerts are unavailable for your role.

) : !alerts.data || alerts.data.length === 0 ? (

No open alerts. The watch loop runs daily and posts clearance expiry, recompete and coverage findings here.

) : (
    {alerts.data.slice(0, 6).map((a) => (
  • {a.title}

    {a.detail ?

    {a.detail}

    : null}
    {a.severity}
  • ))}
)}
{recs.isError ? (

Recommendations are unavailable for your role.

) : !recs.data || recs.data.recommendations.length === 0 ? (

No recommendations yet. These are generated from open requirements and the current bench.

) : (
    {recs.data.recommendations.map((r) => (
  • {r.action} confidence {(num(r.confidence) * 100).toFixed(0)}%

    {r.rationale}

  • ))}
)}
``` Add the two hook calls beside the existing one at the top of the component: ```tsx const alerts = useAlerts() const recs = useRecommendations() ``` These two panels must render their empty state rather than disappear when the feeds are empty, because a new client has no alerts on day one and the copy is what tells them the feed exists. - [ ] **Step 5: Verify it builds and lints** Run: `cd frontend && npm run lint && npm run build` Expected: both succeed. - [ ] **Step 6: Commit** ```bash git add frontend/src/lib/types.ts frontend/src/tools/recruitmatch git commit -m "feat(recruitmatch): alerts and recommended actions panels over the existing feeds" ``` --- ### Task 10: Recruiting intents in the brain Ask the Brain answers from a **closed registry of named intents**. The model selects an intent and binds arguments; it never authors SQL. Adding recruiting questions means adding curated read only SELECTs to that registry, which is the only correct way to give the brain recruiting knowledge. `ValidateRegistry()` runs at boot and **fails the process** if an intent writes, touches an unallowlisted table, or references EEO. So a mistake here does not ship silently, it refuses to start. **Files:** - Modify: `internal/modules/ai/tag.go` - Test: `internal/modules/ai/tag_test.go` **Interfaces:** - Consumes: `QueryIntent`, `Param`, `ParamEnum`, `ParamText`, `ParamInt`, `Registry`, `ValidateRegistry()`. - Produces: four new registry entries. No new types. - [ ] **Step 1: Confirm the tables are allowlisted** The intents below read `requirements`, `opportunities`, `slates`, `slate_members`, `candidates` and `users`. The first five are already read by existing intents. `users` may not be. Run: `grep -n "ReadAllowlist\|readAllowlist" internal/modules/ai/*.go | head` Read the allowlist. If `users` is absent, either add it to the allowlist in the same commit, or drop the `recruiter_decision_throughput` intent from this task. Do not leave it failing at boot. - [ ] **Step 2: Write the failing test** Append to `internal/modules/ai/tag_test.go`: ```go func TestRecruitingIntentsAreRegisteredAndValid(t *testing.T) { want := []string{ "requirements_without_submissions", "available_candidates_not_on_a_slate", "recruiter_decision_throughput", "slates_with_pending_decisions", } for _, name := range want { it, ok := IntentByName(name) if !ok { t.Errorf("intent %q is not registered", name) continue } if !it.IsSQLIntent() { t.Errorf("intent %q must be a SQL intent, never a model intent", name) } if it.Description == "" { t.Errorf("intent %q has no description, so the router cannot select it", name) } } if err := ValidateRegistry(); err != nil { t.Fatalf("ValidateRegistry after adding recruiting intents: %v", err) } } ``` - [ ] **Step 3: Run it to verify it fails** Run: `go test ./internal/modules/ai/ -run TestRecruitingIntents -v` Expected: FAIL with four "is not registered" errors. - [ ] **Step 4: Add the four intents** Append these entries inside the `Registry` slice in `internal/modules/ai/tag.go`, before its closing brace: ```go // Recruiting operations intents. Four curated read only reads that answer the questions a recruiting // lead actually asks. Each reads only tenant scoped tables through the RLS bound app role, carries no // write, and touches no protected attribute. { Name: "requirements_without_submissions", Description: "Which open requirements have no candidates submitted against them yet. The coverage gap a recruiting lead asks about first.", SQL: `SELECT r.id, r.title, COALESCE(r.customer_agency,'') AS customer_agency FROM requirements r LEFT JOIN opportunities o ON o.id = r.opportunity_id LEFT JOIN slates s ON s.requirement_id = r.id LEFT JOIN slate_members sm ON sm.slate_id = s.id WHERE r.deleted_at IS NULL AND (o.id IS NULL OR o.status = 'open') AND ($1 = '' OR r.customer_agency = $1) GROUP BY r.id, r.title, r.customer_agency HAVING count(sm.candidate_id) = 0 ORDER BY r.title`, Params: []Param{{Name: "customer_agency", Kind: ParamText}}, }, { Name: "available_candidates_not_on_a_slate", Description: "Which available candidates have never been submitted to any slate, optionally filtered to one clearance level. The idle bench.", SQL: `SELECT c.id, c.full_name, c.clearance_level FROM candidates c WHERE c.deleted_at IS NULL AND c.anonymized_at IS NULL AND c.available AND NOT EXISTS (SELECT 1 FROM slate_members sm WHERE sm.candidate_id = c.id) AND ($1 = '' OR c.clearance_level = $1) ORDER BY c.full_name`, Params: []Param{{Name: "clearance_level", Kind: ParamEnum, Allowed: []string{"", "none", "confidential", "secret", "top_secret", "ts_sci"}}}, }, { Name: "recruiter_decision_throughput", Description: "How many slate decisions each recruiter has approved and rejected. Attribution of human review, never a performance score.", SQL: `SELECT u.full_name, count(*) FILTER (WHERE sm.decision = 'approved') AS approved, count(*) FILTER (WHERE sm.decision = 'rejected') AS rejected FROM slate_members sm JOIN users u ON u.id = sm.decided_by WHERE sm.decided_at IS NOT NULL GROUP BY u.full_name ORDER BY approved DESC, u.full_name`, Params: []Param{}, }, { Name: "slates_with_pending_decisions", Description: "Which unfinalized slates still have members awaiting a human decision, and how many. The review backlog.", SQL: `SELECT s.id, s.name, s.status, count(sm.candidate_id) AS pending FROM slates s JOIN slate_members sm ON sm.slate_id = s.id WHERE sm.decision = 'pending' AND s.status <> 'finalized' GROUP BY s.id, s.name, s.status ORDER BY pending DESC, s.name LIMIT $1`, Params: []Param{{Name: "limit", Kind: ParamInt, Min: 1, Max: 50}}, }, ``` - [ ] **Step 5: Run the tests to verify they pass** Run: `go test ./internal/modules/ai/ -v` Expected: PASS, including the existing registry validation tests. - [ ] **Step 6: Verify the API still boots** `ValidateRegistry()` is called during startup, so a bad intent is a boot failure rather than a runtime surprise. Run: `go build ./cmd/api && go vet ./internal/modules/ai/` Expected: both succeed. - [ ] **Step 7: Commit** ```bash git add internal/modules/ai/tag.go internal/modules/ai/tag_test.go git commit -m "feat(ai): recruiting operations intents in the closed TAG registry" ``` **What this deliberately does not do.** It does not let a model rank candidates, score anyone, or write anything. The three legal MAG output kinds stay `opportunity_score`, `display_order` and `confidence`, enforced at boot and by a CHECK constraint on `aedt_learned_models.output_kind`. Recruiting is exactly where someone will ask for AI ranking of people. The answer stays no. --- ### Task 11: Org branding backend and shell rendering (DONE) Recorded after implementation so the plan matches reality. Shipped in commit 41a5f7e. - [x] Migration 0046 adds display_name, logo, logo_mime, logo_updated_at to organizations with four CHECK constraints, and widens the column grant to exactly those columns so status and slug stay unwritable. - [x] `internal/modules/org` validates uploads by decoding the bytes rather than trusting the declared content type. PNG and JPEG only, SVG refused, size and dimensions bounded in Go and again in the database. - [x] `GET /api/org/branding`, `GET /api/org/logo`, `PUT /api/org/logo`, `DELETE /api/org/logo`, `PATCH /api/org/branding`. Reads open to every role, writes gated on org.settings.write which is owner only. - [x] AppShell renders the organization mark with a fallback to the product mark. - [x] Verified live: svg 415, html 415, png magic polyglot rejected, one megabyte 413, valid png 200, conditional get 304. ### Task 12: Settings upload UI **This closes a real gap.** Task 11 shipped a working branding API and shell rendering with **no way to use it from the product**. A logo can only be set by issuing a PUT by hand, which no customer will do. The feature is not usable until this lands. **Files:** - Modify: `frontend/src/screens/Settings.tsx` **Interfaces:** - Consumes: `useOrgBranding`, `useUploadLogo`, `useClearLogo`, `useSetDisplayName` from `lib/orgQueries.ts`, and `orgLogoURL` from `lib/org.ts`. - [ ] **Step 1: Add the branding panel** In `frontend/src/screens/Settings.tsx`, render a panel visible only when `isAdmin(principal.role)` is true and the role is owner, since `org.settings.write` is owner only. It needs: the current logo preview or the fallback mark, a file input accepting `image/png,image/jpeg`, an upload button wired to `useUploadLogo`, a remove button wired to `useClearLogo`, and a display name field wired to `useSetDisplayName`. Surface the server error verbatim on failure. The API returns a specific message for each rejection (unsupported type, too large, dimensions too big) and those messages are the whole point: a customer whose SVG is refused needs to know it was the format, not the size. - [ ] **Step 2: Verify the client side accept attribute matches the server allowlist** The input `accept` attribute is a convenience, never a control. Confirm the server still rejects a renamed file chosen through the picker. Run: pick a `.svg` renamed to `.png` and confirm a 415 with the format message. - [ ] **Step 3: Verify and deploy** Run: `cd frontend && npm run lint && npm run build`, then deploy preserving the injected index.html SEO lines. - [ ] **Step 4: Commit** ```bash git add frontend/src/screens/Settings.tsx git commit -m "feat(org): branding upload in settings" ``` --- ### Task 13: Layout pass Deferred by Joseph during Phase 3 with "we can adjust the layout after we are done". **Files:** - Modify: `frontend/src/tools/recruitmatch/RecruiterDashboard.tsx` - [ ] **Step 1: Rebalance the column spans** The first grid row is 5/4/3 where the third column stacks two panels, so the row height is set by the tallest column and leaves a gap under the shorter two. Either even the spans or move one panel into the second row. - [ ] **Step 2: Screenshot at 1280, 1512 and 1920 and confirm no dead space and no horizontal overflow** - [ ] **Step 3: Deploy and commit** --- ### Task 14: Production configuration is deliberate Every item here is currently correct by accident rather than by decision, which is the actual risk. **Files:** - Modify: `/etc/missionmatch/api.env` on Bubbles. No repo change. - [ ] **Step 1: Record the current posture** ```bash ssh user@100.90.97.104 'echo user | sudo -S sed -E "s/=.*/= /" /etc/missionmatch/api.env' ssh user@100.90.97.104 'curl -s http://127.0.0.1:8094/healthz/fips' ``` Expected: `MM_ENV=development`, `fips_enforced:false`, and no `MM_AIOE_DATABASE_URL`. - [ ] **Step 2: Decide AIOE explicitly** AIOE is inert only because `MM_AIOE_DATABASE_URL` is unset. If learning should stay off, set nothing and document that. If it should run, set the DSN **and** `MM_AIOE_SHADOW=true` in the same edit, so the first runs propose weight changes for human promotion instead of auto activating them. Never set the DSN without the shadow flag. - [ ] **Step 3: Decide the FIPS posture** `MM_ENV=production` forces `MM_REQUIRE_FIPS=true`, a non local `MM_CLOUD`, and a real embed provider. That is the correct end state for a federal product but it is a coordinated change, not a flag flip. Confirm each dependency before switching. - [ ] **Step 4: Back up the env file before editing, restart, verify** --- ### Task 15: Bring the model card current `docs/MODEL_CARD.md` documents `v1.1.0` while `engine.ModelVersion` is `v1.4.0`. The card is missing the two v1.4.0 band caps and the clearance compartment check. For a product whose central claim is that its scoring is auditable, the compliance artifact trailing the engine by three versions is the highest severity finding from the codebase scan. **Files:** - Modify: `docs/MODEL_CARD.md` - [ ] **Step 1: Diff the card against the engine** Read `internal/modules/matching/engine/engine.go` and record every difference: the version, the two band caps that lower a band without changing the total, and the compartment check. - [ ] **Step 2: Update the card and add a test that pins the version** A test asserting the card names the current `engine.ModelVersion` stops this drifting again. - [ ] **Step 3: Commit** --- ### Task 16: Frontend deploy safety Every frontend deploy this session hand patched `index.html` to preserve five SEO lines injected after the Vite build. That worked because it was checked each time, and it will silently destroy the canonical link the first time someone forgets. **Files:** - Create: `scripts/deploy-frontend.sh` - [ ] **Step 1: Script the preserved deploy** Build, read the live index.html, swap only the two content hashed asset names, assert the SEO line count is unchanged, rsync assets with delete, copy index.html, verify over HTTP. Fail loudly if the SEO count drops. - [ ] **Step 2: Use it for the next deploy and commit** --- ### Task 17: Integrate Nine commits sit on `feature/aioe-upgrade` and nothing has been pushed. - [ ] **Step 1: Confirm the full gate one more time on the final tree** - [ ] **Step 2: Push the branch** - [ ] **Step 3: Decide whether this merges to master or stays a feature branch pending review** --- ## Backlog, not phases Found during the codebase scan, recorded in the spec, and deliberately NOT part of this build. Each is its own ticket. 1. `/api/opportunities`, `/api/awards` and `/api/wages` have no capability gate at all. 2. `/metrics` is unauthenticated and outside the rate limiter. 3. `ontology_crosswalk` is queried by live code and has zero writers. 4. `internal/platform/federated` and `dp_budget_ledger` have zero importers. 5. Dead buttons with no handler in `CommandCenter.tsx` and `CandidateEvaluation.tsx`. 6. `users.last_login_at` is never written. 7. `CommandCenter.tsx` never destructures `isError`, so a failed fetch shows its loading panel forever. 8. Per tenant database routing remains unmerged on `per-tenant-routing`, 204 commits behind. --- ## Verification After every task is complete, confirm the whole slice end to end: ```bash make build-test-gates go test -tags=integration ./internal/integration/ -run TestRecruitDashboard -v ``` Then run the API locally and confirm the endpoint answers with a session cookie: ```bash curl -s -b cookies.txt https:///api/recruitmatch/dashboard | head -40 curl -s -o /dev/null -w "%{http_code}\n" -b cookies.txt https:///api/recruitmatch/candidates ``` Expected: the dashboard returns the `View` JSON, and the shared picker returns 200 under the new namespace, proving the commingling works. ## Out of scope, tracked separately Org branding is an independent subsystem and gets its own plan. It touches `organizations` with migration 0046, extends `SessionInfo`, adds `GET /api/org/logo` and renders the org mark in `AppShell`. None of it blocks this plan and this plan does not block it.