Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | docs/plans/2026-06-30-ws7-federal-signal.md |
| Original SHA-256 | 7a383996b8f1ba7d65abf9f41043d5ffce8625719b5b85891232305f32884f88 |
| Git blob SHA | 31842b8a44af9cdfa286da9abb70d8f23fca7a15 |
| Reading-copy SHA-256 | 7a383996b8f1ba7d65abf9f41043d5ffce8625719b5b85891232305f32884f88 |
Every recorded occurrence
These 15 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | master | 1e9249840a4302ec82e123b9c9cb6f75badea3e4 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | ws8-teaching-loop | c823f0fb6bb2aba0f39001eb6de96f4000d9321f | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | ws9-bench-ingestion | f585a9ef32fa554663ce1fba9a414a54b6e49cf0 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | vendor-intelligence | fe9796a19c261951d3f2565206c0c1b5ac294465 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | ws7-federal-signal | 6a9f6ce9cfb13345ffb47770d4960e7c2d9c17c2 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
| Janady13/missionmatch / docs/plans/2026-06-30-ws7-federal-signal.md | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
Recovered source transcript
Download complete source text ↓709 lines · 35.5 KB
# WS7 Federal Signal Extraction Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Tag federal opportunities, awards, requirements, and candidates with structured mission areas and incumbent staffing mix using a pure deterministic classifier, so the platform can list open requirements by mission and surface incumbent role mix on a recompete, each inference traceable to its source text.
**Architecture:** A new `mission_areas` code table becomes the single FK source of truth for the 10 IC mission terms. A pure deterministic classifier (keyword gazetteer plus the WS2 alias layer plus a PSC and NAICS crosswalk, no embeddings, no plane call) tags records into multi valued tag tables with mechanism and evidence provenance. A new pure document text extractor plus a server side SAM fetch client mine solicitation attachments for incumbent labor mix. A sync command runs the classifier and writes tags and incumbent roles, audited. The AI retrieval mission filter is upgraded from WS6 text match to a structured predicate, and the Command Center gains open by mission and incumbent mix panels. The frozen deterministic engine is never touched.
**Tech Stack:** Go 1.26, chi, pgx/v5, sqlc v1.31.1, goose v3.24.1, shopspring/decimal, pgvector, FIPS 140-3 (`GOFIPS140=v1.0.0`, `CGO_ENABLED=0`), pure Go PDF and DOCX and HTML text extraction (no Python).
**Branch:** `ws7-federal-signal` off `ws6-retrieval`. Migrations from `0025` (head is `0024`; `0020` reserved by the unmerged per tenant branch). Validate on M4 `brain_staging` only (`joseph@100.94.8.94`), never production. DSNs come from Bubbles `/etc/missionmatch/api.env` with the database name rewritten `missionmatch` to `brain_staging`.
**Hard invariants for every task:** No Python. No dashes in any written output (comments, logs, docs, commit messages) except inside URLs, CLI flags, and code identifiers. Every API key stays server only (load from `/etc/missionmatch/sam-sync.env`). Mission tags are NEVER a scoring input and never reference the eeo schema. Global reference tables are migrator owned, app SELECT only, outside RLS. Tenant tables lead their composite index with `organization_id`, FORCE RLS, and use the `NULLIF(current_setting('app.current_org_id', true),'')::uuid` policy. Every ORDER BY carries a unique id tiebreaker for byte stable output. The frozen engine under `internal/modules/matching/engine` is not modified, and the golden contract (100.00, 87.50, 26.00) stays green.
---
## File Structure
- Create `db/migrations/0025_mission_areas.sql`, global `mission_areas` code table plus seed.
- Create `db/migrations/0026_mission_tags.sql`, global `federal_mission_tags` and `award_incumbent_roles`.
- Create `db/migrations/0027_tenant_mission_tags.sql`, tenant `requirement_mission_tags` and `candidate_mission_tags` with RLS.
- Create `internal/platform/missionclass/missionclass.go`, the pure deterministic mission and labor classifier.
- Create `internal/platform/missionclass/missionclass_test.go`, table driven classifier tests, no DB.
- Create `internal/platform/docextract/docextract.go`, pure Go text extraction for PDF, DOCX, HTML, plain.
- Create `internal/platform/docextract/docextract_test.go`, fixture based extraction tests.
- Create `internal/modules/federalsignal/` (`service.go`, `repo.go`, `fetch.go`), the classification pass and SAM attachment fetch.
- Create `cmd/missionclass-sync/main.go`, the FIPS stamped sync binary.
- Modify `db/queries/opportunity_records.sql`, optional missions filter.
- Modify `db/queries/candidates.sql`, `SearchCandidatesHybridMissionTagged`.
- Modify `internal/modules/ai/retrieval.go`, tagged mission branch in `HybridSearch`.
- Modify `internal/modules/matching/intel.go`, call the shared labor classifier.
- Modify `internal/modules/commandcenter/`, open by mission and incumbent mix panels.
- Modify `docs/STACK_DECISIONS.md`, record the PDF and DOCX dependency and its FIPS posture.
---
## Phase 0: Vocabulary foundation
### Task 1: mission_areas code table
**Files:**
- Create: `db/migrations/0025_mission_areas.sql`
- Test: validate up and down on `brain_staging`
- [ ] **Step 1: Write the migration**
```sql
-- +goose Up
-- mission_areas is the controlled vocabulary of IC mission areas and the single FK source of truth for
-- every mission tag. It is curated from the mission_spine seed (migration 0021) but adds a stable short
-- code that the tag tables and the in code vocabulary key on. Global reference data: migrator owned, app
-- SELECT only, outside RLS, no PII and no eeo reach.
CREATE TABLE mission_areas (
code text PRIMARY KEY,
name text NOT NULL,
description text NOT NULL DEFAULT '',
spine_term text, -- soft link to mission_spine.term for provenance, not an FK
sort_order int NOT NULL DEFAULT 0, -- deterministic iteration order, mirrors ai/mission.go ordering
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO mission_areas (code, name, description, spine_term, sort_order) VALUES
('SIGINT','Signals Intelligence','Intelligence derived from signals, communications, electronic, and foreign instrumentation.','SIGINT',1),
('GEOINT','Geospatial Intelligence','Intelligence from the exploitation and analysis of imagery and geospatial information.','GEOINT',2),
('HUMINT','Human Intelligence','Intelligence collected from human sources.','HUMINT',3),
('MASINT','Measurement and Signature Intelligence','Measurement and signature intelligence.','MASINT',4),
('OSINT','Open Source Intelligence','Intelligence produced from publicly available information.','OSINT',5),
('SAT_C2','Satellite Command and Control','Command and control of satellite systems.','Satellite C2',6),
('CDS','Cross Domain Solution','A controlled interface providing access between security domains.','Cross Domain Solution',7),
('COMSEC','Communications Security','Measures to deny unauthorized access to telecommunications.','COMSEC',8),
('TEMPEST','TEMPEST','Protection against compromising emanations.','TEMPEST',9),
('EW','Electronic Warfare','Military action using electromagnetic and directed energy.','Electronic Warfare',10);
GRANT SELECT ON mission_areas TO missionmatch_app;
-- +goose Down
DROP TABLE IF EXISTS mission_areas;
```
- [ ] **Step 2: Apply up and down on brain_staging from M2**
Run (the goose dance the prior workstreams used, migrator DSN rewritten to brain_staging):
```bash
cd ~/missionmatch
eval "$(ssh user@bubbles 'echo user | sudo -S cat /etc/missionmatch/api.env 2>/dev/null' 2>/dev/null | grep '^MM_MIGRATOR_DATABASE_URL=')"
MIG=$(printf '%s' "$MM_MIGRATOR_DATABASE_URL" | sed 's#/missionmatch?#/brain_staging?#')
go run github.com/pressly/goose/v3/cmd/goose@v3.24.1 -dir db/migrations postgres "$MIG" up
go run github.com/pressly/goose/v3/cmd/goose@v3.24.1 -dir db/migrations postgres "$MIG" down
go run github.com/pressly/goose/v3/cmd/goose@v3.24.1 -dir db/migrations postgres "$MIG" up
```
Expected: up creates the table and seeds 10 rows, down drops it cleanly, up re applies.
- [ ] **Step 3: Regenerate sqlc and commit**
```bash
/Users/josephanady/go/bin/sqlc generate
git add db/migrations/0025_mission_areas.sql internal/platform/database/dbgen/
git commit -m "feat(ws7): mission_areas code table, the single FK source of truth"
```
### Task 2: vocabulary consistency test
**Files:**
- Create: `internal/platform/missionclass/vocab.go` (the in code mission code list)
- Test: `internal/platform/missionclass/vocab_test.go`
- [ ] **Step 1: Write the in code vocabulary as the shared source**
```go
package missionclass
// MissionCode is a stable mission area code. The list mirrors the mission_areas table (migration 0025)
// in BOTH membership and order. A consistency integration test pins this to the table so the two cannot
// drift. ai/mission.go MissionFromQuestion keys on the same codes.
type MissionCode = string
// Codes is the ordered mission vocabulary. Order matches mission_areas.sort_order, so when a record names
// more than one mission the earliest code wins deterministically.
var Codes = []MissionCode{"SIGINT", "GEOINT", "HUMINT", "MASINT", "OSINT", "SAT_C2", "CDS", "COMSEC", "TEMPEST", "EW"}
```
- [ ] **Step 2: Write the unit test (offline)**
```go
package missionclass
import "testing"
func TestCodesAreUniqueAndOrdered(t *testing.T) {
seen := map[string]bool{}
for _, c := range Codes {
if seen[c] {
t.Fatalf("duplicate mission code %q", c)
}
seen[c] = true
}
if len(Codes) != 10 {
t.Fatalf("expected 10 mission codes, got %d", len(Codes))
}
}
```
- [ ] **Step 3: Add the integration consistency test**
Add to `internal/integration/` a test (build tag `integration`) that selects `code` from `mission_areas` ordered by `sort_order` and asserts the slice equals `missionclass.Codes`. This is the drift guard.
- [ ] **Step 4: Run and commit**
Run: `go test ./internal/platform/missionclass/`. Expected PASS. Commit.
---
## Phase 1: The deterministic classifier (pure core)
### Task 3: keyword mission classifier
**Files:**
- Create: `internal/platform/missionclass/missionclass.go`
- Test: `internal/platform/missionclass/missionclass_test.go`
- [ ] **Step 1: Write the failing test**
```go
package missionclass
import "testing"
func TestClassifyKeyword(t *testing.T) {
cases := []struct {
text string
want []string // mission codes, in Codes order
}{
{"Provide SIGINT collection and COMSEC support", []string{"SIGINT", "COMSEC"}},
{"signals intelligence analysis for the customer", []string{"SIGINT"}},
{"satellite command and control operations", []string{"SAT_C2"}},
{"a cross domain solution accreditation effort", []string{"CDS"}},
{"general administrative support services", nil},
{"", nil},
// bare acronym must not match inside a longer word
{"the cdsa framework", nil},
}
for _, tc := range cases {
got := codesOf(ClassifyKeyword(tc.text))
if !equal(got, tc.want) {
t.Fatalf("ClassifyKeyword(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
```
(Provide small `codesOf` and `equal` helpers in the test file.)
- [ ] **Step 2: Run to verify it fails**
Run: `go test ./internal/platform/missionclass/ -run TestClassifyKeyword`. Expected FAIL (undefined `ClassifyKeyword`).
- [ ] **Step 3: Implement the keyword classifier**
`Hit` carries the provenance the acceptance criterion requires. `ClassifyKeyword` runs a word boundary matcher over the lowercased text for each mission code's forms, returns one `Hit` per matched code in `Codes` order, deduped. Forms mirror `ai/mission.go` (acronym plus long phrase, whitespace tolerant). Mechanism is `keyword`, evidence is the matched phrase.
```go
package missionclass
import (
"regexp"
"sort"
"strings"
)
// Mechanism records HOW a mission was inferred, so every tag is traceable (the WS7 acceptance criterion).
const (
MechKeyword = "keyword"
MechAlias = "alias"
MechPSC = "psc"
MechNAICS = "naics"
)
// Hit is one mission inference with its provenance.
type Hit struct {
Code string
Mechanism string
Evidence string // the matched phrase or code
}
type form struct {
code string
re *regexp.Regexp
text string // canonical evidence phrase for this form
}
func wbForm(code, phrase string) form {
return form{code: code, text: phrase,
re: regexp.MustCompile(`(?i)\b` + strings.ReplaceAll(regexp.QuoteMeta(phrase), " ", `\s+`) + `\b`)}
}
// missionForms is iterated in Codes order. Keep in sync with ai/mission.go missionVocabulary.
var missionForms = []form{
wbForm("SIGINT", "sigint"), wbForm("SIGINT", "signals intelligence"),
wbForm("GEOINT", "geoint"), wbForm("GEOINT", "geospatial intelligence"),
wbForm("HUMINT", "humint"), wbForm("HUMINT", "human intelligence"),
wbForm("MASINT", "masint"), wbForm("MASINT", "measurement and signature intelligence"),
wbForm("OSINT", "osint"), wbForm("OSINT", "open source intelligence"),
wbForm("SAT_C2", "satellite c2"), wbForm("SAT_C2", "satellite command and control"),
wbForm("CDS", "cross domain solution"), wbForm("CDS", "cross domain solutions"),
wbForm("COMSEC", "comsec"), wbForm("COMSEC", "communications security"),
wbForm("TEMPEST", "tempest"),
wbForm("EW", "electronic warfare"),
}
// ClassifyKeyword returns one Hit per mission code whose keyword form appears in text, in Codes order.
func ClassifyKeyword(text string) []Hit {
if strings.TrimSpace(text) == "" {
return nil
}
found := map[string]Hit{}
for _, f := range missionForms {
if _, ok := found[f.code]; ok {
continue
}
if f.re.MatchString(text) {
found[f.code] = Hit{Code: f.code, Mechanism: MechKeyword, Evidence: f.text}
}
}
return ordered(found)
}
// ordered returns the hits sorted by the canonical Codes order, for byte stable output.
func ordered(m map[string]Hit) []Hit {
rank := map[string]int{}
for i, c := range Codes {
rank[c] = i
}
out := make([]Hit, 0, len(m))
for _, h := range m {
out = append(out, h)
}
sort.Slice(out, func(i, j int) bool { return rank[out[i].Code] < rank[out[j].Code] })
return out
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `go test ./internal/platform/missionclass/ -run TestClassifyKeyword`. Expected PASS.
- [ ] **Step 5: Commit**
```bash
git add internal/platform/missionclass/
git commit -m "feat(ws7): deterministic keyword mission classifier with provenance"
```
### Task 4: alias driven mission inference
**Files:**
- Modify: `internal/platform/missionclass/missionclass.go`
- Test: `internal/platform/missionclass/missionclass_test.go`
- [ ] **Step 1: Write the failing test**
The alias path resolves a mission bearing skill or alias mention to a mission code via an injected map (no DB), mirroring the WS2 resolver test pattern `newResolverFromMaps`. Example: an `AliasIndex` mapping `"signals intelligence"` and `"sigint analyst"` to code `SIGINT`. `ClassifyAlias("hiring a SIGINT Analyst", idx)` returns a `SIGINT` hit with mechanism `alias` and evidence the matched alias.
```go
func TestClassifyAlias(t *testing.T) {
idx := NewAliasIndex(map[string]string{
"sigint analyst": "SIGINT",
"signals intelligence": "SIGINT",
"comsec custodian": "COMSEC",
})
got := codesOf(ClassifyAlias("seeking a SIGINT Analyst and a COMSEC Custodian", idx))
want := []string{"SIGINT", "COMSEC"}
if !equal(got, want) {
t.Fatalf("ClassifyAlias = %v, want %v", got, want)
}
}
```
- [ ] **Step 2: Run to verify it fails.** Expected FAIL (undefined `NewAliasIndex`, `ClassifyAlias`).
- [ ] **Step 3: Implement the alias index and classifier**
```go
// AliasIndex maps a lowercased mission bearing phrase to a mission code. It is built from the curated WS2
// alias rows and the category='mission_area' skills, so the alias layer (not a cosine threshold) carries
// acronym and synonym knowledge. Phrases are matched on a word boundary, whitespace tolerant.
type AliasIndex struct {
forms []form
}
func NewAliasIndex(phraseToCode map[string]string) AliasIndex {
keys := make([]string, 0, len(phraseToCode))
for k := range phraseToCode {
keys = append(keys, k)
}
sort.Strings(keys) // deterministic build order
ai := AliasIndex{}
for _, k := range keys {
ai.forms = append(ai.forms, wbForm(phraseToCode[k], k))
}
return ai
}
// ClassifyAlias returns one Hit per mission code whose alias phrase appears in text, in Codes order.
func ClassifyAlias(text string, idx AliasIndex) []Hit {
if strings.TrimSpace(text) == "" {
return nil
}
found := map[string]Hit{}
for _, f := range idx.forms {
if _, ok := found[f.code]; ok {
continue
}
if f.re.MatchString(text) {
found[f.code] = Hit{Code: f.code, Mechanism: MechAlias, Evidence: f.text}
}
}
return ordered(found)
}
```
- [ ] **Step 4: Run to verify it passes.** Commit `feat(ws7): alias driven mission inference reusing the WS2 alias layer`.
### Task 5: PSC and NAICS crosswalk
**Files:**
- Modify: `internal/platform/missionclass/missionclass.go`
- Create: `internal/platform/missionclass/crosswalk.go` (the curated code map)
- Test: `internal/platform/missionclass/missionclass_test.go`
- [ ] **Step 1: Write the failing test**
```go
func TestClassifyCodes(t *testing.T) {
// AC* is national defense R&D, mapped to defense missions; D3 is IT; unknown maps to nothing.
if got := codesOf(ClassifyCodes("AC12", "")); !contains(got, "SIGINT") && !contains(got, "EW") {
// the exact mapping is curated; assert the curated expectation for the seeded code
}
if got := ClassifyCodes("ZZZZ", "999999"); len(got) != 0 {
t.Fatalf("unknown PSC and NAICS should yield no mission, got %v", got)
}
}
```
(Author the positive assertions against the curated map you seed in Step 3; keep them exact.)
- [ ] **Step 2: Run to verify it fails.**
- [ ] **Step 3: Implement the curated crosswalk**
A small explicit map from PSC prefix and NAICS code to mission codes, conservative (only codes with a defensible mission signal). Mechanism `psc` or `naics`, evidence the code. Document that this is curated and deliberately narrow to avoid false positives, and that broad codes map to nothing.
```go
// pscMission maps a PSC code prefix to mission codes. Deliberately narrow: only codes with a defensible
// mission signal are mapped, so a generic code produces no tag rather than a wrong one.
var pscMission = map[string][]string{
"AC": {"SIGINT", "EW"}, // R&D, National Defense (curated, conservative)
// extend with vetted entries only
}
// naicsMission maps a full NAICS code to mission codes (curated, conservative).
var naicsMission = map[string][]string{
"541330": {}, // Engineering Services: too broad to assign a mission, intentionally empty
}
func ClassifyCodes(psc, naics string) []Hit {
found := map[string]Hit{}
psc = strings.ToUpper(strings.TrimSpace(psc))
for prefix, codes := range pscMission {
if strings.HasPrefix(psc, prefix) {
for _, c := range codes {
found[c] = Hit{Code: c, Mechanism: MechPSC, Evidence: psc}
}
}
}
if codes, ok := naicsMission[strings.TrimSpace(naics)]; ok {
for _, c := range codes {
found[c] = Hit{Code: c, Mechanism: MechNAICS, Evidence: naics}
}
}
return ordered(found)
}
// Classify unions the keyword, alias, and code mechanisms and dedupes by code, keeping the FIRST mechanism
// seen in priority order keyword, alias, code (so the most direct evidence is recorded). Result is in
// Codes order, byte stable.
func Classify(text, psc, naics string, idx AliasIndex) []Hit {
merged := map[string]Hit{}
for _, h := range ClassifyKeyword(text) {
if _, ok := merged[h.Code]; !ok {
merged[h.Code] = h
}
}
for _, h := range ClassifyAlias(text, idx) {
if _, ok := merged[h.Code]; !ok {
merged[h.Code] = h
}
}
for _, h := range ClassifyCodes(psc, naics) {
if _, ok := merged[h.Code]; !ok {
merged[h.Code] = h
}
}
return ordered(merged)
}
```
- [ ] **Step 4: Test the union `Classify` too** (a record with both a keyword and a code hit dedupes to one tag, keyword mechanism wins). Run, then commit `feat(ws7): PSC and NAICS mission crosswalk and unified Classify`.
### Task 6: shared labor classifier
**Files:**
- Modify: `internal/platform/missionclass/missionclass.go` (add `ClassifyLabor`)
- Modify: `internal/modules/matching/intel.go` (call the shared classifier)
- Test: `internal/platform/missionclass/missionclass_test.go`, `internal/modules/matching/intel_test.go`
- [ ] **Step 1: Move the 8 entry `laborKeywords` (intel.go:96 to 108) into missionclass as data and write `ClassifyLabor(text string) []string`** returning the matched LCAT names in a stable order, word boundary matched. Keep the exact same words so candidate output is unchanged.
- [ ] **Step 2: Write a golden test** that `ClassifyLabor` over a sample headline returns the SAME LCATs the old inline logic returned (lift representative cases from `intel_test.go`).
- [ ] **Step 3: Rewire `CandidateIntelligence`** (intel.go) to call `missionclass.ClassifyLabor` instead of the inline list. Delete the inline `laborKeywords`.
- [ ] **Step 4: Run the matching suite** `go test ./internal/modules/matching/...` and confirm candidate intelligence output is byte identical (the golden/regression tests pass). The FROZEN engine is untouched; only the intel helper changed. Commit `refactor(ws7): shared labor classifier used by candidates and opportunities`.
---
## Phase 2: Tagging tables and queries
### Task 7: global mission tag and incumbent role tables
**Files:**
- Create: `db/migrations/0026_mission_tags.sql`
- [ ] **Step 1: Write the migration**
```sql
-- +goose Up
-- federal_mission_tags tags global federal records (opportunity_records, usaspending_awards) with one or
-- more mission areas, each carrying the mechanism and the evidence phrase so the inference is traceable.
-- Global reference data: migrator owned writes, app SELECT only, outside RLS, no PII and no eeo reach.
CREATE TABLE federal_mission_tags (
record_table text NOT NULL CHECK (record_table IN ('opportunity_records','usaspending_awards')),
record_id text NOT NULL,
mission_code text NOT NULL REFERENCES mission_areas(code),
mechanism text NOT NULL,
evidence text NOT NULL DEFAULT '',
confidence numeric(3,2) NOT NULL DEFAULT 1.0,
classified_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (record_table, record_id, mission_code)
);
CREATE INDEX idx_federal_mission_tags_code ON federal_mission_tags (mission_code, record_table);
GRANT SELECT ON federal_mission_tags TO missionmatch_app;
-- award_incumbent_roles holds the incumbent labor mix mined from award and solicitation text, with the
-- evidence phrase. Counts come only from genuine text matches, never fabricated.
CREATE TABLE award_incumbent_roles (
record_table text NOT NULL CHECK (record_table IN ('opportunity_records','usaspending_awards','sam_opportunities')),
record_id text NOT NULL,
labor_category text NOT NULL,
role_count int NOT NULL DEFAULT 0,
evidence text NOT NULL DEFAULT '',
classified_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (record_table, record_id, labor_category)
);
GRANT SELECT ON award_incumbent_roles TO missionmatch_app;
-- +goose Down
DROP TABLE IF EXISTS award_incumbent_roles;
DROP TABLE IF EXISTS federal_mission_tags;
```
- [ ] **Step 2: Apply up and down on brain_staging** (same goose dance as Task 1). Regenerate sqlc. Commit.
### Task 8: tenant mission tag tables with RLS
**Files:**
- Create: `db/migrations/0027_tenant_mission_tags.sql`
- [ ] **Step 1: Write the migration** (copy the `candidate_skills` RLS pattern from `0002_matching.sql:100 to 112` verbatim for both tables).
```sql
-- +goose Up
CREATE TABLE requirement_mission_tags (
organization_id uuid NOT NULL,
requirement_id uuid NOT NULL,
mission_code text NOT NULL REFERENCES mission_areas(code),
mechanism text NOT NULL,
evidence text NOT NULL DEFAULT '',
PRIMARY KEY (requirement_id, mission_code),
FOREIGN KEY (organization_id, requirement_id) REFERENCES requirements (organization_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_requirement_mission_tags_org ON requirement_mission_tags (organization_id, requirement_id);
ALTER TABLE requirement_mission_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE requirement_mission_tags FORCE ROW LEVEL SECURITY;
CREATE POLICY requirement_mission_tags_isolation ON requirement_mission_tags
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON requirement_mission_tags TO missionmatch_app;
CREATE TABLE candidate_mission_tags (
organization_id uuid NOT NULL,
candidate_id uuid NOT NULL,
mission_code text NOT NULL REFERENCES mission_areas(code),
mechanism text NOT NULL,
evidence text NOT NULL DEFAULT '',
PRIMARY KEY (candidate_id, mission_code),
FOREIGN KEY (organization_id, candidate_id) REFERENCES candidates (organization_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_candidate_mission_tags_org ON candidate_mission_tags (organization_id, candidate_id);
ALTER TABLE candidate_mission_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE candidate_mission_tags FORCE ROW LEVEL SECURITY;
CREATE POLICY candidate_mission_tags_isolation ON candidate_mission_tags
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON candidate_mission_tags TO missionmatch_app;
-- +goose Down
DROP TABLE IF EXISTS candidate_mission_tags;
DROP TABLE IF EXISTS requirement_mission_tags;
```
- [ ] **Step 2: Apply up and down on brain_staging.** Regenerate sqlc. Commit.
### Task 9: sqlc queries for tags
**Files:**
- Create: `db/queries/mission_tags.sql`
- Modify: generated `dbgen`
- [ ] **Step 1: Author the queries** (named args): `UpsertFederalMissionTag`, `UpsertAwardIncumbentRole`, `UpsertCandidateMissionTag`, `UpsertRequirementMissionTag`, `ListMissionTagsForRecord`, `ListCandidatesByMission` (RLS scoped). Every list query ends `ORDER BY ... , id` for byte stability. Use `ON CONFLICT ... DO UPDATE` so the sync pass is idempotent.
- [ ] **Step 2: Regenerate sqlc, build, and write a small integration test** proving a tenant tag round trips and cannot be read cross tenant (copy the existing RLS isolation test shape). Run on brain_staging. Commit.
---
## Phase 3: Document extraction and SAM fetch (attachment mining)
### Task 10: pure Go document text extractor
**Files:**
- Create: `internal/platform/docextract/docextract.go`
- Test: `internal/platform/docextract/docextract_test.go`
- Modify: `docs/STACK_DECISIONS.md` (record the dependency and FIPS posture)
- [ ] **Step 1: Decide the PDF dependency against STACK_DECISIONS.** Use a pure Go PDF text extractor that builds under `CGO_ENABLED=0` (candidates: `github.com/ledongthuc/pdf`, `rsc.io/pdf`). Confirm it is CGO free (`go list -deps` shows no `C` package) and record it in `docs/STACK_DECISIONS.md` the way excelize is recorded (a dead path note if it drags any md4 or ripemd160; it must stay out of `cmd/api`). DOCX and HTML use the standard library only.
- [ ] **Step 2: Write the failing test** with three tiny fixture files committed under `testdata/`: a one page PDF containing the text `SIGINT Analyst x3`, a DOCX containing `ISSO x2`, and an HTML file containing `<p>COMSEC Custodian</p>`. Assert `ExtractText` returns text containing those phrases.
```go
func TestExtractText(t *testing.T) {
for _, tc := range []struct{ file, want string }{
{"testdata/sample.pdf", "SIGINT Analyst"},
{"testdata/sample.docx", "ISSO"},
{"testdata/sample.html", "COMSEC Custodian"},
{"testdata/sample.txt", "Cyber Engineer"},
} {
b, _ := os.ReadFile(tc.file)
got, err := ExtractText(context.Background(), tc.file, b)
if err != nil {
t.Fatalf("ExtractText(%s): %v", tc.file, err)
}
if !strings.Contains(got, tc.want) {
t.Fatalf("ExtractText(%s) missing %q in %q", tc.file, tc.want, got)
}
}
}
```
- [ ] **Step 3: Implement `ExtractText(ctx, filename string, data []byte) (string, error)`** dispatching on the lowercased extension: `.pdf` via the pure Go PDF reader, `.docx` via `archive/zip` plus `encoding/xml` over `word/document.xml`, `.htm`/`.html` via tag stripping (`golang.org/x/net/html` tokenizer or a conservative stripper), `.txt` and unknown via raw bytes. Cap the output length (for example 1 MB of text) and return a typed error on an unsupported or corrupt file. No network, no Python, pure Go.
- [ ] **Step 4: Run** `CGO_ENABLED=0 GOFIPS140=v1.0.0 go test ./internal/platform/docextract/`. Expected PASS. Commit `feat(ws7): pure Go PDF DOCX HTML text extractor`.
### Task 11: SAM attachment fetch client
**Files:**
- Create: `internal/modules/federalsignal/fetch.go`
- Test: `internal/modules/federalsignal/fetch_test.go`
- [ ] **Step 1: Write the failing test** against an `httptest` server: the client fetches a `description_url` (append the api_key) and a `resource_links` entry, runs the bytes through `docextract.ExtractText`, and returns the concatenated text. The api_key is read from config, never logged, never in the URL it returns. Use a fake key in the test.
- [ ] **Step 2: Implement the fetch client** reusing the sam-sync HTTP client style (timeout, bounded retries). Signature: `FetchSolicitationText(ctx, descURL string, resourceLinks []string) (string, error)`. It appends `&api_key=...` to the SAM URLs (the key from `config`), downloads each, caps total bytes, extracts text via `docextract`, and concatenates with a separator. On a per attachment error it logs a metric and continues (best effort), never failing the whole record. It optionally stores the raw bytes via `storage.Storer.Put` under a deterministic key for retention.
- [ ] **Step 3: Run, then commit** `feat(ws7): server side SAM attachment fetch and text extraction`.
---
## Phase 4: The classification sync pass
### Task 12: missionclass-sync command
**Files:**
- Create: `internal/modules/federalsignal/service.go`, `internal/modules/federalsignal/repo.go`
- Create: `cmd/missionclass-sync/main.go`
- Test: `internal/modules/federalsignal/service_test.go` (offline, fixture rows)
- [ ] **Step 1: Write the failing service test (offline)** with in memory fixture records: given a few `opportunity_records` and `usaspending_awards` rows (title plus description text, psc, naics) and a stub fetch client returning fixed attachment text, the service produces the expected `federal_mission_tags` and `award_incumbent_roles` with evidence. No DB, no network: inject the classifier, an `AliasIndex` built from a fixture map, and a fake fetch client and repo.
- [ ] **Step 2: Implement the service `ClassifyAll(ctx)`** that builds the `AliasIndex` once from the curated alias and mission_area skill rows (a repo read), iterates the global records, runs `missionclass.Classify` over the record text plus, for SAM backed solicitations, the fetched attachment text, runs the labor classifier for incumbent roles, and upserts tags and roles idempotently. It writes ONE append only audit event per run (action `aedt.federalsignal.classified`, counts only, no PII), mirroring the oppintel sync audit shape.
- [ ] **Step 3: Implement `cmd/missionclass-sync/main.go`** as a FIPS stamped binary (the in build stamp gate like the other sync commands), wiring config, db (migrator role), the inference free classifier, the SAM fetch client, and the storage port. It is a cron candidate like `mm-opp-sync`.
- [ ] **Step 4: Build with the FIPS flags, run the offline service test.** Commit `feat(ws7): missionclass-sync classification and incumbent mix pass`.
- [ ] **Step 5 (needs federal data, staging gate):** Seed `usaspending_awards` and `sam_opportunities` into `brain_staging` by running `cmd/awards-sync` and `cmd/sam-sync` against the staging DSN (the staging award feed is currently empty). Then run `missionclass-sync` against staging and confirm tags and incumbent roles are written with evidence. Capture counts.
---
## Phase 5: Retrieval upgrade (additive, default unchanged)
### Task 13: structured mission query
**Files:**
- Modify: `db/queries/candidates.sql`
- [ ] **Step 1: Author `SearchCandidatesHybridMissionTagged`** identical to `SearchCandidatesHybridMission` but the mission constraint on BOTH arms is `EXISTS (SELECT 1 FROM candidate_mission_tags t WHERE t.candidate_id = candidates.id AND t.mission_code = sqlc.arg(mission_code))` instead of the `search_tsv` text match. Keep every ORDER BY id tiebreaker. Regenerate sqlc.
- [ ] **Step 2: Write a live integration test** seeding tagged and untagged candidates and asserting only the tagged candidates return, RLS scoped, byte stable. Run on brain_staging. Commit.
### Task 14: HybridSearch tagged branch
**Files:**
- Modify: `internal/modules/ai/retrieval.go`
- Test: `internal/modules/ai/` and integration
- [ ] **Step 1: Add a tagged branch to `HybridSearch`** preferred when the candidate has structured tags available: when `mission != ""`, call `SearchCandidatesHybridMissionTagged` with the mission CODE (map the canonical keyword from `MissionFromQuestion` to the code). Keep the WS6 text query as a documented fallback only if no tags exist yet (a config or a simple has tags probe), so the default unscoped path stays byte identical and EEO and RLS guards remain on the path. Update `docs/AI_RESEARCH.md` to note the WS6 to WS7 upgrade (text filter to structured predicate).
- [ ] **Step 2: Test** the tagged branch returns only tagged candidates, the unnamed mission path is unchanged, and `ForbidEEO` still fires. Run unit plus integration on brain_staging. Commit.
---
## Phase 6: Command Center surfacing
### Task 15: list opportunities by mission
**Files:**
- Modify: `db/queries/opportunity_records.sql`
- [ ] **Step 1: Add an optional `missions::text[]` narg** to `ListOpportunityRecords` (the query already takes optional array nargs with a NULL means no filter default, `opportunity_records.sql:56 to 66`): `AND (sqlc.narg('missions')::text[] IS NULL OR EXISTS (SELECT 1 FROM federal_mission_tags t WHERE t.record_table = 'opportunity_records' AND t.record_id = opportunity_records.id::text AND t.mission_code = ANY(sqlc.narg('missions'))))`. Default NULL keeps current behavior. Regenerate sqlc.
- [ ] **Step 2: Test** the default path is unchanged and the mission filtered path returns only tagged records. Run on brain_staging. Commit.
### Task 16: Command Center mission and incumbent panels
**Files:**
- Modify: `internal/modules/commandcenter/service.go`, `dto.go`, `handler.go`
- [ ] **Step 1: Add an open requirements by mission view and an incumbent staffing mix view.** Open by mission lists active solicitations filtered by mission code. Incumbent mix joins `award_incumbent_roles` to the recompete signal (`usaspending_awards.pop_end_date`) and shows the role counts with their evidence phrase (the acceptance criterion: each inference traceable to its source text). These are additive panels; the existing readiness math is unchanged.
- [ ] **Step 2: Wire the DTO and handler**, keep it read only and RLS scoped, label incumbent data with its provenance. AEDT and EEO untouched (this reads public federal data, never the eeo schema, never a scoring input).
- [ ] **Step 3 (needs federal data):** Integration test on brain_staging after the Task 12 staging seed, proving the panels populate and each row carries evidence. Commit.
---
## Phase 7: Close out
### Task 17: review, validate, and prepare the merge
- [ ] **Step 1: Dispatch the final adversarial review** over the whole WS7 diff (AEDT determinism, EEO isolation, RLS, no Python, no dashes, both cloud, the polymorphic FK tradeoff, the document extractor FIPS posture). Fix findings.
- [ ] **Step 2: Confirm the frozen engine is untouched** and the golden and frozen tests are green; stamp verify the new binaries.
- [ ] **Step 3: Run the full integration suite on brain_staging** (after the award and SAM seed) and capture the result. The pre existing `usaspending_awards=0` gap is closed by the Task 12 seed.
- [ ] **Step 4: Update the memory file** and `docs/AI_GROUNDING_PROGRAM.md` WS7 status. Do not merge to master or deploy to production without explicit instruction; the whole grounding stack merges together.
---
## Self review notes
- Spec coverage: every WS7 deliverable D1 through D7 maps to a task (D1 Task 1, D2 Tasks 7 and 8, D3 Tasks 3 to 5, D4 Tasks 10 to 12, D5 Task 6, D6 Tasks 15 and 16, D7 Tasks 13 and 14).
- The frozen engine is never in a modify list. The only `internal/modules/matching` change is the intel labor helper (Task 6), guarded by a byte identical regression test.
- Two tasks need live federal data (Task 12 step 5, Task 16 step 3); both are gated on seeding `brain_staging`, which the plan calls out explicitly. Everything else is offline unit testable.
- Migration order: 0025 code table before 0026 and 0027 tag tables (FK dependency), tenant tables after the global ones.