A continuation of Janady13/missionmatch / docs/superpowers/plans/2026-07-20-recruitmatch.md. The complete source identity, every recorded occurrence, and linked equations are on its first page.
Source transcript · continued
Download complete source text ↓1,925 lines · 69.9 KB
```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
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<Panel title="Alerts" hint={alerts.data ? `${alerts.data.length} unread` : 'loading'}>
{alerts.isError ? (
<p className="py-6 text-center text-sm text-warn">Alerts are unavailable for your role.</p>
) : !alerts.data || alerts.data.length === 0 ? (
<p className="py-6 text-center text-sm text-faint">
No open alerts. The watch loop runs daily and posts clearance expiry, recompete and coverage findings here.
</p>
) : (
<ul className="divide-y divide-line/50">
{alerts.data.slice(0, 6).map((a) => (
<li key={a.id} className="flex items-start justify-between gap-3 py-2.5">
<div>
<p className="text-sm font-semibold">{a.title}</p>
{a.detail ? <p className="mt-0.5 text-[11px] text-faint">{a.detail}</p> : null}
</div>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide"
style={{
color: a.severity === 'urgent' ? 'var(--color-risk)' : a.severity === 'watch' ? 'var(--color-warn)' : 'var(--color-muted)',
}}
>
{a.severity}
</span>
</li>
))}
</ul>
)}
</Panel>
<Panel title="Recommended actions">
{recs.isError ? (
<p className="py-6 text-center text-sm text-warn">Recommendations are unavailable for your role.</p>
) : !recs.data || recs.data.recommendations.length === 0 ? (
<p className="py-6 text-center text-sm text-faint">
No recommendations yet. These are generated from open requirements and the current bench.
</p>
) : (
<ul className="divide-y divide-line/50">
{recs.data.recommendations.map((r) => (
<li key={r.id} className="py-2.5">
<div className="flex items-baseline justify-between">
<span className="text-sm font-semibold capitalize">{r.action}</span>
<span className="text-[11px] tabular-nums text-faint">
confidence {(num(r.confidence) * 100).toFixed(0)}%
</span>
</div>
<p className="mt-0.5 text-[11px] text-muted">{r.rationale}</p>
</li>
))}
</ul>
)}
</Panel>
</div>
```
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/=.*/= <set>/" /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://<host>/api/recruitmatch/dashboard | head -40
curl -s -o /dev/null -w "%{http_code}\n" -b cookies.txt https://<host>/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.