ThatAIguyA world of ideas

THE CONSCIOUSNESS ATLAS
Read. Experiment. Follow the evidence.

ConsciousnessLearn · Explore · Joseph Anady

Janady13/missionmatch / docs/plans/2026-06-29-ws1-ontology-sync.md

Source identity, revision, and recovered contents.

GitHub sourceSource text recovered

Source identity

FieldRecorded value
Source typeGitHub source
Original recordJanady13/missionmatch / docs/plans/2026-06-29-ws1-ontology-sync.md
DateNot specified in source
RepositoryJanady13/missionmatch
Revision720d47fae476b0d38477ae4401697d372b1ea2bc
Branchfeature/opportunity-to-outcome
Path or archive memberdocs/plans/2026-06-29-ws1-ontology-sync.md
Original SHA-256450b574f92f130d30f211a49edcae2427b18866b475ddca10997c500d343b06f
Git blob SHA0ccd67039b0f3d306e8e5a0410bfe4285b915450
Reading-copy SHA-256450b574f92f130d30f211a49edcae2427b18866b475ddca10997c500d343b06f

Original source ↗

Every recorded occurrence

These 3 records contain identical original source bytes. Their paths and revisions remain part of the archive.

Original recordBranch or archiveRevision / identifierOriginal location
Janady13/missionmatch / docs/plans/2026-06-29-ws1-ontology-sync.mdfeature/opportunity-to-outcome720d47fae476b0d38477ae4401697d372b1ea2bcOpen original
Janady13/missionmatch / docs/plans/2026-06-29-ws1-ontology-sync.mdmaster1e9249840a4302ec82e123b9c9cb6f75badea3e4Open original
Janady13/missionmatch / docs/plans/2026-06-29-ws1-ontology-sync.mdfeature/aioe-upgrade2178e7298d0e5d9af83afa12402fd587f0ba6ed8Open original

Recovered source transcript

Download complete source text ↓1,020 lines · 39.4 KB
# WS1 Ontology Sync 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:** Build `cmd/ontology-sync`, a Go command that ingests the GovCon vocabulary from free federal sources into a new global ontology store, so the matcher has hundreds of real cleared workforce terms to match against instead of 13. **Architecture:** Mirror the proven `cmd/opp-sync` adapter pattern. A new `internal/modules/ontology` package defines a `VocabRecord`, an `OntologySource` interface, and an `Ingester` that retains each raw payload to object storage, upserts the normalized record into a new global `ontology_terms` table (migrator writes, app role reads), and writes one audit event per source. Adapters under `internal/modules/ontology/adapters` implement each source. The IC mission spine is a curated table seeded by the migration, not an adapter. This plan delivers the foundation plus the keyless adapters; O*NET ships dormant behind a missing key. **Tech Stack:** Go 1.26, pgx/v5 (`pgxpool`), goose migrations, `github.com/xuri/excelize/v2` (pure Go XLSX reader, new dependency), the existing `internal/platform/storage` Storer and `internal/platform/audit` Writer, the existing `internal/platform/extraction` deterministic extractor. CGO_ENABLED=0, GOFIPS140=v1.0.0, no Python. **Scope note:** This is WS1 of the AI Grounding Program (see `docs/AI_GROUNDING_PROGRAM.md`). WS2 (alias and relationship layer), WS3 (clearance modeling), and Phases 2 and 3 each get their own plan. WS1 creates the staging and reference tables (`ontology_terms`, `ontology_crosswalk`, `mission_spine`); the matching facing promotion of these terms into the global `skills` table happens in WS2. --- ## File Structure | File | Responsibility | New or Modify | | --- | --- | --- | | `db/migrations/0021_ontology.sql` | the three global ontology tables plus the seeded mission spine | Create | | `internal/modules/ontology/record.go` | `VocabRecord`, `TermKind` closed set | Create | | `internal/modules/ontology/source.go` | `OntologySource` interface, `ErrThrottled` | Create | | `internal/modules/ontology/ingest.go` | `Ingester` (retain, upsert, audit), `RecordWriter`, `Auditor`, `IngestResult` | Create | | `internal/modules/ontology/writer.go` | `DBWriter` upsert into `ontology_terms` via `pool.Exec` | Create | | `internal/modules/ontology/ingest_test.go` | ingester unit tests with `storage.NewMemory` and fakes | Create | | `internal/modules/ontology/adapters/psc.go` | PSC manual XLSX adapter + `parsePSC` | Create | | `internal/modules/ontology/adapters/psc_test.go` | `parsePSC` unit test on an in memory XLSX | Create | | `internal/modules/ontology/adapters/corpus.go` | mines our retained SAM and award text via the extractor | Create | | `internal/modules/ontology/adapters/corpus_test.go` | corpus adapter unit test | Create | | `internal/modules/ontology/adapters/nice.go` | NICE Framework components JSON adapter | Create | | `internal/modules/ontology/adapters/calc.go` | GSA CALC labor category sweep adapter | Create | | `internal/modules/ontology/adapters/onet.go` | O*NET adapter, dormant without `ONET_API_KEY` | Create | | `cmd/ontology-sync/main.go` | the CLI: wire sources, loop ingest, log | Create | | `Makefile` | add the `ontology-sync` FIPS build target | Modify | | `go.mod` / `go.sum` | add `github.com/xuri/excelize/v2` | Modify | --- ## Task 1: Migration 0021, the global ontology tables **Files:** - Create: `db/migrations/0021_ontology.sql` - [ ] **Step 1: Write the migration** ```sql -- +goose Up -- WS1 ontology staging and reference tables (AI Grounding Program). Like skills, sam_opportunities, -- and opportunity_records these are GLOBAL PUBLIC reference data, NOT tenant data: the federal -- vocabulary is identical for every org, so these tables carry NO organization_id and sit OUTSIDE Row -- Level Security. The runtime app role gets SELECT only; cmd/ontology-sync writes as the migrator role. -- ontology_terms is the raw pulled vocabulary with provenance; ontology_crosswalk links codes across -- frameworks (NICE to DCWF, SOC to skill); mission_spine is the hand curated IC vocabulary, seeded here. CREATE TABLE ontology_terms ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), source_name text NOT NULL, -- provenance label, for example PSC Manual source_record_id text NOT NULL, -- the source's own id (upsert key with source_name) term_kind text NOT NULL CHECK (term_kind IN ('skill','labor_category','work_role','ksa','psc_code','mission_area')), term text NOT NULL DEFAULT '', -- the canonical text code text NOT NULL DEFAULT '', -- the source code (PSC code, SOC code, NICE element id) category text NOT NULL DEFAULT '', description text NOT NULL DEFAULT '', raw_object_key text NOT NULL DEFAULT '', -- object storage key for the retained raw payload raw_sha256 text NOT NULL DEFAULT '', -- integrity stamp of the retained payload fetched_at timestamptz NOT NULL, synced_at timestamptz NOT NULL DEFAULT now(), UNIQUE (source_name, source_record_id) ); CREATE INDEX idx_ontology_terms_kind ON ontology_terms (term_kind); CREATE INDEX idx_ontology_terms_code ON ontology_terms (code); CREATE TABLE ontology_crosswalk ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), from_system text NOT NULL, -- for example NICE from_code text NOT NULL, to_system text NOT NULL, -- for example DCWF to_code text NOT NULL, relation text NOT NULL DEFAULT 'maps_to', source_name text NOT NULL DEFAULT '', synced_at timestamptz NOT NULL DEFAULT now(), UNIQUE (from_system, from_code, to_system, to_code) ); CREATE TABLE mission_spine ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), version text NOT NULL, term text NOT NULL, canonical_definition text NOT NULL DEFAULT '', authority text NOT NULL DEFAULT '', -- ODNI, CNSSI 4009, JP 3-14 source_doc text NOT NULL DEFAULT '', source_url text NOT NULL DEFAULT '', effective_date date, UNIQUE (version, term) ); -- Starter curated spine (v1). Expanded by hand; each term cites its governing authority. INSERT INTO mission_spine (version, term, canonical_definition, authority, source_doc) VALUES ('v1','SIGINT','Intelligence derived from signals, communications, electronic, and foreign instrumentation.','ODNI','Intelligence disciplines'), ('v1','GEOINT','Intelligence from the exploitation and analysis of imagery and geospatial information.','ODNI','Intelligence disciplines'), ('v1','HUMINT','Intelligence collected from human sources.','ODNI','Intelligence disciplines'), ('v1','MASINT','Measurement and signature intelligence.','ODNI','Intelligence disciplines'), ('v1','OSINT','Intelligence produced from publicly available information.','ODNI','IC OSINT Strategy'), ('v1','Satellite C2','Command and control of satellite systems.','JP 3-14','Space Operations'), ('v1','Cross Domain Solution','A controlled interface providing manual or automated access between security domains.','CNSSI 4009','National Information Assurance Glossary'), ('v1','COMSEC','Communications security: measures to deny unauthorized access to telecommunications.','CNSSI 4009','National Information Assurance Glossary'), ('v1','TEMPEST','Protection against compromising emanations.','CNSSI 4009','National Information Assurance Glossary'), ('v1','Electronic Warfare','Military action using electromagnetic and directed energy.','JP 3-13.1','Electronic Warfare'); GRANT SELECT ON ontology_terms TO missionmatch_app; GRANT SELECT ON ontology_crosswalk TO missionmatch_app; GRANT SELECT ON mission_spine TO missionmatch_app; -- +goose Down DROP TABLE IF EXISTS mission_spine; DROP TABLE IF EXISTS ontology_crosswalk; DROP TABLE IF EXISTS ontology_terms; ``` - [ ] **Step 2: Apply and roll back on a scratch database to prove both directions** Run (local scratch or M4 staging migrator DSN): ```bash goose -dir db/migrations postgres "$MM_MIGRATOR_DATABASE_URL" up goose -dir db/migrations postgres "$MM_MIGRATOR_DATABASE_URL" status # 0021 applied, head v21 goose -dir db/migrations postgres "$MM_MIGRATOR_DATABASE_URL" down # drops the three tables goose -dir db/migrations postgres "$MM_MIGRATOR_DATABASE_URL" up # reapply, leave applied ``` Expected: up creates three tables and seeds 10 mission_spine rows; down drops cleanly; status shows head v21. - [ ] **Step 3: Commit** ```bash git add db/migrations/0021_ontology.sql git commit -m "feat(ontology): add WS1 global ontology tables and seeded mission spine" ``` --- ## Task 2: VocabRecord and the OntologySource interface **Files:** - Create: `internal/modules/ontology/record.go` - Create: `internal/modules/ontology/source.go` - Test: `internal/modules/ontology/record_test.go` - [ ] **Step 1: Write the failing test** ```go package ontology import "testing" func TestTermKindValid(t *testing.T) { for _, k := range []TermKind{KindSkill, KindLaborCategory, KindWorkRole, KindKSA, KindPSC, KindMissionArea} { if !k.Valid() { t.Fatalf("expected %q valid", k) } } if TermKind("bogus").Valid() { t.Fatal("expected bogus invalid") } } ``` - [ ] **Step 2: Run to verify it fails** Run: `go test ./internal/modules/ontology/ -run TestTermKindValid` Expected: FAIL, undefined: TermKind. - [ ] **Step 3: Write record.go** ```go // Package ontology ingests the GovCon vocabulary from free federal sources into the global ontology // store. It mirrors the opportunity_intelligence ingestion shape: a streaming source contract, a single // ingester that retains each raw payload and upserts the normalized record, and one audit event per run. package ontology import "encoding/json" // TermKind is the closed set of vocabulary record kinds. A closed set keeps the global table queryable // and lets the WS2 promotion step reason about provenance. type TermKind string const ( KindSkill TermKind = "skill" KindLaborCategory TermKind = "labor_category" KindWorkRole TermKind = "work_role" KindKSA TermKind = "ksa" KindPSC TermKind = "psc_code" KindMissionArea TermKind = "mission_area" ) // Valid reports whether k is one of the allowed kinds (it must match the CHECK in migration 0021). func (k TermKind) Valid() bool { switch k { case KindSkill, KindLaborCategory, KindWorkRole, KindKSA, KindPSC, KindMissionArea: return true } return false } // VocabRecord is one normalized vocabulary item from any ontology source. SourceName and SourceRecordID // form the upsert key. Term is the canonical text, Code is the source's own identifier (a PSC code, a SOC // code, a NICE element id), Category groups it, and Raw is the retained source payload. type VocabRecord struct { SourceName string SourceRecordID string Kind TermKind Term string Code string Category string Description string Raw json.RawMessage } ``` - [ ] **Step 4: Write source.go** ```go package ontology import ( "context" "errors" ) // ErrThrottled signals a source hit its provider rate limit (a 429). The ingester treats it as a soft // stop: it keeps and reports whatever was ingested before the limit. var ErrThrottled = errors.New("ontology: source rate limited") // OntologySource is the provider adapter contract, one per free federal vocabulary feed. Each handles its // own paging internally and calls yield once per normalized record. A yield error stops the pull and is // returned, so a storage or database failure aborts the run fail closed. type OntologySource interface { // Name is the provenance label stamped on every record this source yields (for example PSC Manual). Name() string // Fetch pulls the source's scope and calls yield once per normalized record. On a provider rate limit // it returns ErrThrottled so the ingester can stop that source gracefully. Fetch(ctx context.Context, yield func(VocabRecord) error) error } ``` - [ ] **Step 5: Run to verify it passes** Run: `go test ./internal/modules/ontology/ -run TestTermKindValid` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add internal/modules/ontology/record.go internal/modules/ontology/source.go internal/modules/ontology/record_test.go git commit -m "feat(ontology): VocabRecord and OntologySource contract" ``` --- ## Task 3: The Ingester, RecordWriter, Auditor, and DBWriter **Files:** - Create: `internal/modules/ontology/ingest.go` - Create: `internal/modules/ontology/writer.go` - Test: `internal/modules/ontology/ingest_test.go` - [ ] **Step 1: Write the failing test** (uses `storage.NewMemory` and fakes, no database) ```go package ontology import ( "context" "encoding/json" "testing" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/storage" ) type fakeSource struct{ recs []VocabRecord } func (f *fakeSource) Name() string { return "Fake" } func (f *fakeSource) Fetch(_ context.Context, yield func(VocabRecord) error) error { for _, r := range f.recs { if err := yield(r); err != nil { return err } } return nil } type fakeWriter struct{ got []VocabRecord } func (w *fakeWriter) Upsert(_ context.Context, rec VocabRecord, key, sha string) error { if key == "" || sha == "" { return assertErr("expected non empty raw key and sha") } w.got = append(w.got, rec) return nil } type fakeAuditor struct{ events int } func (a *fakeAuditor) System(_ context.Context, _ audit.Event) error { a.events++; return nil } func assertErr(s string) error { return &simpleErr{s} } type simpleErr struct{ s string } func (e *simpleErr) Error() string { return e.s } func TestIngesterRetainsUpsertsAudits(t *testing.T) { src := &fakeSource{recs: []VocabRecord{ {SourceName: "Fake", SourceRecordID: "R375", Kind: KindPSC, Term: "Maintenance of Aircraft", Code: "R375", Raw: json.RawMessage(`{"code":"R375"}`)}, {SourceRecordID: "AC12", Kind: KindPSC, Term: "National Defense R and D", Code: "AC12"}, }} w := &fakeWriter{} a := &fakeAuditor{} ing := NewIngester(storage.NewMemory(), w, a) res, err := ing.Ingest(context.Background(), src) if err != nil { t.Fatalf("ingest: %v", err) } if res.Fetched != 2 || res.Upserted != 2 { t.Fatalf("counts: %+v", res) } if len(w.got) != 2 { t.Fatalf("writer got %d", len(w.got)) } if w.got[1].SourceName != "Fake" { t.Fatal("empty SourceName should default to the source Name") } if a.events != 1 { t.Fatalf("expected one audit event, got %d", a.events) } } func TestIngesterRejectsInvalidKind(t *testing.T) { src := &fakeSource{recs: []VocabRecord{{SourceRecordID: "X", Kind: "bogus"}}} ing := NewIngester(storage.NewMemory(), &fakeWriter{}, &fakeAuditor{}) if _, err := ing.Ingest(context.Background(), src); err == nil { t.Fatal("expected an error on an invalid kind") } } ``` - [ ] **Step 2: Run to verify it fails** Run: `go test ./internal/modules/ontology/ -run TestIngester` Expected: FAIL, undefined: NewIngester. - [ ] **Step 3: Write ingest.go** ```go package ontology import ( "context" "encoding/json" "errors" "fmt" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/storage" ) // RecordWriter persists a normalized record. The pool backed implementation (NewDBWriter) upserts into // ontology_terms; tests use a fake. rawObjectKey and rawSHA256 are the storage pointer and integrity stamp. type RecordWriter interface { Upsert(ctx context.Context, rec VocabRecord, rawObjectKey, rawSHA256 string) error } // Auditor records the platform ingest event. The concrete audit.Writer satisfies it through System. type Auditor interface { System(ctx context.Context, e audit.Event) error } // IngestResult is the real, counted outcome of one source pull. Every count comes from records actually // retained and written, never a fabricated number. type IngestResult struct { Source string `json:"source"` Fetched int `json:"fetched"` Upserted int `json:"upserted"` ByKind map[string]int `json:"by_kind"` } // Ingester pulls a source, retains each raw payload in object storage, upserts the normalized record, and // writes one audit event for the run. It is the single ingestion path shared by every ontology source. type Ingester struct { store storage.Storer writer RecordWriter auditor Auditor } // NewIngester builds an ingester. All three dependencies are required. func NewIngester(store storage.Storer, writer RecordWriter, auditor Auditor) *Ingester { return &Ingester{store: store, writer: writer, auditor: auditor} } // Ingest pulls every record from src, retaining and upserting each, then writes the ingest audit event. A // storage or database failure aborts the pull fail closed. A provider rate limit is a soft stop. The audit // write is fail closed, and because every upsert is idempotent a rerun is safe. func (i *Ingester) Ingest(ctx context.Context, src OntologySource) (IngestResult, error) { res := IngestResult{Source: src.Name(), ByKind: map[string]int{}} err := src.Fetch(ctx, func(rec VocabRecord) error { if rec.SourceName == "" { rec.SourceName = src.Name() } if !rec.Kind.Valid() { return fmt.Errorf("ontology: record %q from %s has invalid kind %q", rec.SourceRecordID, res.Source, rec.Kind) } if rec.SourceRecordID == "" { return fmt.Errorf("ontology: record from %s has an empty source record id", res.Source) } key, sha, serr := i.retainRaw(ctx, rec) if serr != nil { return serr } if werr := i.writer.Upsert(ctx, rec, key, sha); werr != nil { return werr } res.Fetched++ res.Upserted++ res.ByKind[string(rec.Kind)]++ return nil }) if err != nil && !errors.Is(err, ErrThrottled) { return res, err } if aerr := i.audit(ctx, res); aerr != nil { return res, aerr } return res, nil } func (i *Ingester) retainRaw(ctx context.Context, rec VocabRecord) (string, string, error) { payload := rec.Raw if len(payload) == 0 { payload = json.RawMessage("{}") } key := rawKey(rec.SourceName, rec.SourceRecordID) sha := storage.SHA256Hex(payload) if err := i.store.Put(ctx, key, payload); err != nil { return "", "", fmt.Errorf("ontology: retain raw: %w", err) } return key, sha, nil } func (i *Ingester) audit(ctx context.Context, res IngestResult) error { body, err := json.Marshal(res) if err != nil { return fmt.Errorf("ontology: marshal ingest audit: %w", err) } return i.auditor.System(ctx, audit.Event{ Action: "ontology.ingest", EntityType: "ontology_source", EntityID: res.Source, NewValues: body, }) } func rawKey(source, id string) string { return "ontology_terms/" + slug(source) + "/" + slug(id) + ".json" } func slug(s string) string { out := make([]rune, 0, len(s)) for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_': out = append(out, r) case r >= 'A' && r <= 'Z': out = append(out, r+('a'-'A')) default: out = append(out, '_') } } if len(out) == 0 { return "unknown" } return string(out) } ``` - [ ] **Step 4: Write writer.go** ```go package ontology import ( "context" "github.com/jackc/pgx/v5/pgxpool" ) // DBWriter upserts a VocabRecord into the global ontology_terms table. It runs as the migrator role (the // only role granted write on the global reference tables). The upsert key is (source_name, source_record_id), // so a rerun refreshes a term in place and the command is idempotent. type DBWriter struct{ pool *pgxpool.Pool } // NewDBWriter builds a DBWriter over a migrator pool. func NewDBWriter(pool *pgxpool.Pool) *DBWriter { return &DBWriter{pool: pool} } // Upsert writes one record, refreshing it on a (source_name, source_record_id) conflict. func (w *DBWriter) Upsert(ctx context.Context, rec VocabRecord, rawObjectKey, rawSHA256 string) error { _, err := w.pool.Exec(ctx, ` INSERT INTO ontology_terms (source_name, source_record_id, term_kind, term, code, category, description, raw_object_key, raw_sha256, fetched_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now()) ON CONFLICT (source_name, source_record_id) DO UPDATE SET term_kind=EXCLUDED.term_kind, term=EXCLUDED.term, code=EXCLUDED.code, category=EXCLUDED.category, description=EXCLUDED.description, raw_object_key=EXCLUDED.raw_object_key, raw_sha256=EXCLUDED.raw_sha256, synced_at=now()`, rec.SourceName, rec.SourceRecordID, string(rec.Kind), rec.Term, rec.Code, rec.Category, rec.Description, rawObjectKey, rawSHA256) return err } ``` - [ ] **Step 5: Run to verify it passes** Run: `go test ./internal/modules/ontology/ -run TestIngester` Expected: PASS (both tests). - [ ] **Step 6: Commit** ```bash git add internal/modules/ontology/ingest.go internal/modules/ontology/writer.go internal/modules/ontology/ingest_test.go git commit -m "feat(ontology): ingester, db writer, and audited retain path" ``` --- ## Task 4: The PSC adapter (XLSX), the first real source **Files:** - Create: `internal/modules/ontology/adapters/psc.go` - Test: `internal/modules/ontology/adapters/psc_test.go` - Modify: `go.mod`, `go.sum` (add excelize) - [ ] **Step 1: Add the excelize dependency** Run: `go get github.com/xuri/excelize/v2@latest` Expected: go.mod and go.sum updated; excelize is pure Go (no cgo), so the FIPS build is unaffected. - [ ] **Step 2: Write the failing test** (build a tiny XLSX in memory, then parse it) ```go package adapters import ( "bytes" "testing" "github.com/xuri/excelize/v2" "github.com/missionworkforce/missionmatch/internal/modules/ontology" ) func buildPSCXLSX(t *testing.T) []byte { t.Helper() f := excelize.NewFile() sh := f.GetSheetName(0) _ = f.SetCellValue(sh, "A1", "PSC CODE") _ = f.SetCellValue(sh, "B1", "PRODUCT AND SERVICE CODE NAME") _ = f.SetCellValue(sh, "A2", "R425") _ = f.SetCellValue(sh, "B2", "Engineering and Technical Services") _ = f.SetCellValue(sh, "A3", "AC12") _ = f.SetCellValue(sh, "B3", "National Defense R and D Services") var buf bytes.Buffer if err := f.Write(&buf); err != nil { t.Fatalf("write xlsx: %v", err) } return buf.Bytes() } func TestParsePSC(t *testing.T) { recs, err := parsePSC(buildPSCXLSX(t)) if err != nil { t.Fatalf("parse: %v", err) } if len(recs) != 2 { t.Fatalf("want 2 records, got %d", len(recs)) } if recs[0].Code != "R425" || recs[0].Kind != ontology.KindPSC { t.Fatalf("bad first record: %+v", recs[0]) } if recs[1].Term != "National Defense R and D Services" { t.Fatalf("bad second term: %q", recs[1].Term) } if recs[0].SourceRecordID != "R425" { t.Fatalf("source record id should be the code: %q", recs[0].SourceRecordID) } } ``` - [ ] **Step 3: Run to verify it fails** Run: `go test ./internal/modules/ontology/adapters/ -run TestParsePSC` Expected: FAIL, undefined: parsePSC. - [ ] **Step 4: Write psc.go** ```go package adapters import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/xuri/excelize/v2" "github.com/missionworkforce/missionmatch/internal/modules/ontology" ) // PSCSource ingests the federal Product and Service Code manual, the cleanest fully enumerable procurement // taxonomy. It reads the published XLSX (a URL or a local file) and yields one KindPSC record per code. type PSCSource struct { url string client *http.Client } // NewPSCSource builds the adapter. url is the PSC manual XLSX location (acquisition.gov publishes it). func NewPSCSource(url string, client *http.Client) *PSCSource { if client == nil { client = &http.Client{Timeout: 60 * time.Second} } return &PSCSource{url: url, client: client} } // Name is the provenance label. func (s *PSCSource) Name() string { return "PSC Manual" } // Fetch downloads the XLSX and yields one record per code. func (s *PSCSource) Fetch(ctx context.Context, yield func(ontology.VocabRecord) error) error { data, err := s.download(ctx) if err != nil { return err } recs, err := parsePSC(data) if err != nil { return err } for _, r := range recs { if err := yield(r); err != nil { return err } } return nil } func (s *PSCSource) download(ctx context.Context) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil) if err != nil { return nil, fmt.Errorf("psc: new request: %w", err) } resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("psc: get: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("psc: provider returned %s", resp.Status) } return io.ReadAll(resp.Body) } // parsePSC reads the PSC manual XLSX bytes and returns one VocabRecord per code. It finds the code and name // columns by header text, so a column reorder in a later manual revision does not break it. func parsePSC(data []byte) ([]ontology.VocabRecord, error) { f, err := excelize.OpenReader(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("psc: open xlsx: %w", err) } defer f.Close() sheet := f.GetSheetName(0) rows, err := f.GetRows(sheet) if err != nil { return nil, fmt.Errorf("psc: read rows: %w", err) } if len(rows) < 2 { return nil, fmt.Errorf("psc: sheet has no data rows") } codeCol, nameCol := -1, -1 for i, h := range rows[0] { switch { case strings.Contains(strings.ToUpper(h), "PSC") && strings.Contains(strings.ToUpper(h), "CODE"): codeCol = i case strings.Contains(strings.ToUpper(h), "NAME"): nameCol = i } } if codeCol < 0 || nameCol < 0 { return nil, fmt.Errorf("psc: could not find the code and name columns in the header %v", rows[0]) } out := make([]ontology.VocabRecord, 0, len(rows)-1) for _, row := range rows[1:] { if codeCol >= len(row) || nameCol >= len(row) { continue } code := strings.TrimSpace(row[codeCol]) name := strings.TrimSpace(row[nameCol]) if code == "" || name == "" { continue } raw, _ := json.Marshal(map[string]string{"code": code, "name": name}) out = append(out, ontology.VocabRecord{ SourceName: "PSC Manual", SourceRecordID: code, Kind: ontology.KindPSC, Term: name, Code: code, Category: "procurement", Raw: raw, }) } return out, nil } ``` - [ ] **Step 5: Run to verify it passes** Run: `go test ./internal/modules/ontology/adapters/ -run TestParsePSC` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add internal/modules/ontology/adapters/psc.go internal/modules/ontology/adapters/psc_test.go go.mod go.sum git commit -m "feat(ontology): PSC manual XLSX adapter" ``` --- ## Task 5: The CLI, the Makefile target, and the FIPS build **Files:** - Create: `cmd/ontology-sync/main.go` - Modify: `Makefile` - [ ] **Step 1: Write cmd/ontology-sync/main.go** ```go // Command ontology-sync ingests the GovCon vocabulary from free federal sources into the global // ontology_terms table, mirroring cmd/opp-sync. It runs as the migrator role and is safe to rerun: every // upsert is idempotent. The O*NET adapter is included only when ONET_API_KEY is set; the others need no key. // // Env: MM_MIGRATOR_DATABASE_URL (or MM_DATABASE_URL), MM_ONTOLOGY_RAW_DIR (object storage root, default // ./data/ontology-raw), MM_PSC_XLSX_URL (the PSC manual XLSX URL), ONET_API_KEY (optional, dormant adapter). package main import ( "context" "log" "net/http" "os" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/missionworkforce/missionmatch/internal/modules/ontology" "github.com/missionworkforce/missionmatch/internal/modules/ontology/adapters" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/storage" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) func main() { dsn := firstNonEmpty(os.Getenv("MM_MIGRATOR_DATABASE_URL"), os.Getenv("MM_DATABASE_URL")) if dsn == "" { log.Fatal("ontology-sync: set MM_MIGRATOR_DATABASE_URL (the migrator DSN)") } rawDir := firstNonEmpty(os.Getenv("MM_ONTOLOGY_RAW_DIR"), "./data/ontology-raw") pscURL := strings.TrimSpace(os.Getenv("MM_PSC_XLSX_URL")) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() pool, err := pgxpool.New(ctx, dsn) if err != nil { log.Fatalf("ontology-sync: connect: %v", err) } defer pool.Close() store, err := storage.NewLocal(rawDir) if err != nil { log.Fatalf("ontology-sync: storage: %v", err) } ing := ontology.NewIngester(store, ontology.NewDBWriter(pool), audit.New(tenant.New(pool))) var sources []ontology.OntologySource if pscURL != "" { sources = append(sources, adapters.NewPSCSource(pscURL, &http.Client{Timeout: 60 * time.Second})) } else { log.Print("ontology-sync: MM_PSC_XLSX_URL not set, skipping the PSC adapter") } // The corpus adapter (Task 6) and the nice and calc adapters (Tasks 7 and 8) append here. // The O*NET adapter is dormant until its key lands: if k := strings.TrimSpace(os.Getenv("ONET_API_KEY")); k != "" { sources = append(sources, adapters.NewONETSource(k, nil)) } else { log.Print("ontology-sync: ONET_API_KEY not set, O*NET adapter is dormant (skipped)") } if len(sources) == 0 { log.Fatal("ontology-sync: no sources configured") } var grand int for _, src := range sources { res, ierr := ing.Ingest(ctx, src) if ierr != nil { log.Fatalf("ontology-sync: ingest %s: %v", src.Name(), ierr) } log.Printf("ontology-sync: %s fetched %d upserted %d by_kind %v", res.Source, res.Fetched, res.Upserted, res.ByKind) grand += res.Upserted } log.Printf("ontology-sync: done, %d terms upserted into ontology_terms", grand) } func firstNonEmpty(vals ...string) string { for _, v := range vals { if s := strings.TrimSpace(v); s != "" { return s } } return "" } ``` Note: this references `adapters.NewONETSource`, defined in Task 9. Until Task 9 lands, comment out the O*NET block or implement Task 9 first. The recommended order is Task 6 (corpus) then 7, 8, 9. - [ ] **Step 2: Add the Makefile build target** Modify `Makefile`: in the `build` target add the line (after the `opp-sync` line, line 16), and add `ontology-sync` to the `.PHONY` list: ```makefile CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOFIPS140=v1.0.0 $(GO) build -trimpath -o ontology-sync ./cmd/ontology-sync ``` - [ ] **Step 3: Build it FIPS clean and verify the stamp** Run: ```bash CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOFIPS140=v1.0.0 go build -trimpath -o /tmp/ontology-sync ./cmd/ontology-sync go version -m /tmp/ontology-sync | grep GOFIPS140 ``` Expected: builds clean, prints `GOFIPS140=v1.0.0`. - [ ] **Step 4: Commit** ```bash git add cmd/ontology-sync/main.go Makefile git commit -m "feat(ontology): ontology-sync CLI and FIPS build target" ``` --- ## Task 6: The corpus adapter (mine our own retained SAM and award text) **Files:** - Create: `internal/modules/ontology/adapters/corpus.go` - Test: `internal/modules/ontology/adapters/corpus_test.go` This adapter needs no external API. It reads the description and raw text we already retain in `sam_opportunities`, `usaspending_awards`, and `opportunity_records`, runs the existing deterministic extractor (`internal/platform/extraction`, `NewDeterministic().ExtractRequirement(ctx, text)`), and yields one `KindSkill` or `KindLaborCategory` VocabRecord per distinct extracted term, deduped, with `SourceName` "Corpus Mining" and `SourceRecordID` the lowercased term. - [ ] **Step 1: Read `internal/platform/extraction` to confirm the `RawExtraction` field that holds proposed skills (the slice the extractor returns), so the adapter maps it exactly.** - [ ] **Step 2: Write the failing test** with a stub text reader returning two opportunity blurbs, asserting the adapter yields deduped skill terms. ```go package adapters import ( "context" "testing" "github.com/missionworkforce/missionmatch/internal/modules/ontology" ) type stubText struct{ blobs []string } func (s stubText) Texts(_ context.Context, yield func(string) error) error { for _, b := range s.blobs { if err := yield(b); err != nil { return err } } return nil } func TestCorpusYieldsDedupedSkills(t *testing.T) { src := NewCorpusSource(stubText{blobs: []string{ "Seeking a SIGINT analyst with Splunk and AWS experience.", "Cloud engineer with AWS and Kubernetes.", }}) seen := map[string]int{} err := src.Fetch(context.Background(), func(r ontology.VocabRecord) error { seen[r.Term]++ if r.Kind != ontology.KindSkill && r.Kind != ontology.KindLaborCategory { t.Fatalf("unexpected kind %q", r.Kind) } return nil }) if err != nil { t.Fatalf("fetch: %v", err) } if seen["AWS"] != 1 { t.Fatalf("AWS should be yielded once (deduped), got %d", seen["AWS"]) } } ``` - [ ] **Step 3: Write corpus.go** with a `TextReader` interface (`Texts(ctx, yield func(string) error) error`), a `NewCorpusSource(TextReader)` constructor, a pool backed `TextReader` that selects `description`/`raw` from the three tables, and a `Fetch` that runs the extractor per text and yields each distinct term once. Reuse `extraction.NewDeterministic()`. - [ ] **Step 4: Run to verify it passes.** Run: `go test ./internal/modules/ontology/adapters/ -run TestCorpus`. Expected: PASS. - [ ] **Step 5: Wire it into the CLI** (append `adapters.NewCorpusSource(adapters.NewPoolText(pool))` to `sources`), then commit. ```bash git add internal/modules/ontology/adapters/corpus.go internal/modules/ontology/adapters/corpus_test.go cmd/ontology-sync/main.go git commit -m "feat(ontology): corpus mining adapter over retained federal text" ``` --- ## Task 7: The NICE Framework adapter (components JSON) **Files:** Create `internal/modules/ontology/adapters/nice.go` and `nice_test.go`. The NICE Framework components are published as a pinned JSON file (`csrc.nist.gov/csrc/media/Projects/cprt/documents/nice/v2-2-0_nf_components.json`), public domain, no auth. The 4 element schema has `elements` (each with an `element_identifier`, `element_type` like Work Role or Competency Area or TKS, and `text`) and `relationships`. Mirror the PSC adapter shape: a `download` plus a pure `parseNICE([]byte) ([]ontology.VocabRecord, error)` that yields KindWorkRole for work roles and KindKSA for TKS statements, keyed on `element_identifier`. Test `parseNICE` on a small literal JSON fixture. Commit. --- ## Task 8: The GSA CALC adapter (labor category sweep) **Files:** Create `internal/modules/ontology/adapters/calc.go` and `calc_test.go`. CALC ceiling rates (`api.gsa.gov/acquisition/calc/v3/api/ceilingrates/`, no auth, optional `DATA_GOV_API_KEY` to lift the gateway rate limit) has no catalog endpoint, so enumerate labor categories by sweeping `?q=<seed>` across a seed list and deduping the `labor_category` field from the JSON results into KindLaborCategory records. Inject the HTTP client and the seed list so the test runs against an `httptest.Server` returning a canned page. Send `DATA_GOV_API_KEY` as the `api_key` query param when set (we already hold it). Commit. --- ## Task 9: The O*NET adapter (dormant without a key) **Files:** Create `internal/modules/ontology/adapters/onet.go` and `onet_test.go`. `NewONETSource(apiKey string, client *http.Client)` sends the key as the `X-API-Key` header (never the query string), lists occupations and pulls skills, knowledge, abilities, and tasks per SOC code, throttling to 5 requests per second with a 200ms backoff on 429, keyed on the content model element id, yielding KindSkill and KindKSA. The CLI only constructs it when `ONET_API_KEY` is set, so it is inert until the key lands. Test the JSON parser on a small literal fixture (no live call). Add the required attribution string ("incorporates information from O*NET Web Services by the U.S. Department of Labor") to the source's records or a run log. Commit. --- ## Task 10: DCWF crosswalk (open publications), deferred detail **Files:** Create `internal/modules/ontology/adapters/dcwf.go` and `dcwf_test.go`. The DCWF KSAT detail is CAC gated and DoD CIO pages reject fetchers, so this adapter ingests the open NICE to DCWF crosswalk (the 72 work roles and their 3 digit codes) into `ontology_crosswalk` rather than scraping the gated tool. Because the open artifact is a PDF, this task carries a real risk: pure Go PDF table extraction is unreliable. The pragmatic implementation is to commit a small curated crosswalk CSV (the 72 role to code mappings, transcribed once from the open factsheet) under `internal/modules/ontology/adapters/data/dcwf_crosswalk.csv`, embedded with `go:embed`, and parsed deterministically. Test the CSV parser on a small fixture. Write to `ontology_crosswalk`. Commit. (This keeps the no Python rule and avoids brittle PDF scraping; it is the deliberate small curation the spec allows for the gated source.) --- ## Task 11: Validate end to end on M4 staging - [ ] **Step 1: Apply migration 0021 on a fresh M4 staging database** (not production): ```bash ssh joseph@100.94.8.94 'cd ~/missionmatch && \ /opt/homebrew/opt/postgresql@16/bin/psql -U postgres -c "CREATE DATABASE ontology_staging"' # provision roles + run the full migration set against ontology_staging via goose as migrator ``` - [ ] **Step 2: Run ontology-sync against staging** with `MM_PSC_XLSX_URL` set to the live PSC manual and no O*NET key: ```bash MM_MIGRATOR_DATABASE_URL="postgres://missionmatch_migrator:...@100.94.8.94:5432/ontology_staging?sslmode=disable" \ MM_ONTOLOGY_RAW_DIR=./data/ontology-raw \ MM_PSC_XLSX_URL="https://www.acquisition.gov/sites/default/files/manual/PSC%20April%202025.xlsx" \ /tmp/ontology-sync ``` Expected log: `ontology-sync: PSC Manual fetched N upserted N ...`, `ONET_API_KEY not set ... dormant`, `done, N terms upserted`. - [ ] **Step 3: Verify the store and the audit**: ```bash ssh joseph@100.94.8.94 '/opt/homebrew/opt/postgresql@16/bin/psql -U postgres -d ontology_staging -tAc \ "SELECT term_kind, count(*) FROM ontology_terms GROUP BY 1; SELECT count(*) FROM mission_spine; SELECT count(*) FROM audit_logs WHERE action='\''ontology.ingest'\'';"' ``` Expected: psc_code rows present, mission_spine has 10 rows, one ontology.ingest audit per source. - [ ] **Step 4: Run the full unit suite and the gates**: ```bash go test ./internal/modules/ontology/... go build ./... make verify-fips ``` Expected: all green. - [ ] **Step 5: Final commit**: ```bash git add -A git commit -m "test(ontology): WS1 validated on M4 staging (psc + corpus, onet dormant)" ``` --- ## Self-Review Run after the plan is written, before execution. 1. **Spec coverage:** WS1 in `docs/AI_GROUNDING_PROGRAM.md` calls for `cmd/ontology-sync` with six adapters, the staging tables, mission_spine, and M4 validation. Covered: foundation (Tasks 1 to 3), psc (4), CLI and FIPS (5), corpus (6), nice (7), calc (8), onet dormant (9), dcwf crosswalk (10), M4 validation (11). The matching facing promotion into `skills` is correctly deferred to WS2 per the spec. 2. **Placeholder scan:** Tasks 1 to 5 carry complete code. Tasks 6 to 10 are scoped with concrete interfaces, the exact source mechanics, and a named test, but defer full parser bodies to execution time; Task 6 Step 1 and Task 10 name the precise lookup or artifact rather than hand waving. This is a deliberate decomposition: the machinery (1 to 5) is the novel risk and is fully specified; the remaining adapters repeat the established pattern. 3. **Type consistency:** `VocabRecord`, `TermKind`, `OntologySource`, `RecordWriter.Upsert(ctx, rec, key, sha)`, `Ingester`, and `NewDBWriter` are used identically across tasks. The CLI references `adapters.NewONETSource` (Task 9) and `adapters.NewCorpusSource`/`NewPoolText` (Task 6), flagged in Task 5 with the recommended build order. Known follow on: split Tasks 7 to 10 into their own short plans if executing subagent driven, so each adapter is one reviewable unit.

Search equations, builds, manuscript text, and recovered sources.