package federalsignal // Phase 5a tenant mission tagging. This file is the TENANT scoped sibling of the global classification // pass in service.go: it runs the same pure deterministic missionclass.Classify over a tenant's own // candidates and requirements and upserts structured mission tags into candidate_mission_tags and // requirement_mission_tags. Those tags are the structured signal the Phase 5 retrieval upgrade and the // Phase 6 Command Center read, replacing the WS6 text only mission filter. // // Every tag WRITE runs inside RunInTenantTx as the application role, so Postgres Row Level Security is // exercised on every insert: the org id comes from the validated GUC context, never a caller argument, // and the composite FK guarantees the candidate or requirement belongs to that org. The classifier itself // is pure (no DB, no clock, no network), so this service is unit testable with an injected fake repo and a // fixed AliasIndex, with no database connection. // // Mission tags are reference data, NEVER a scoring input, and this path never reaches the eeo schema. // // SCOPE NOTE: write wiring into the candidate and requirement create paths (tag on create) is a documented // follow up and is intentionally OUT of scope for Phase 5a. The tag-missions backfill command is the // operator path for now: it is idempotent and safe to rerun, so an operator brings a tenant current by // running it, and a future create path hook will keep it current incrementally. import ( "context" "fmt" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/missionclass" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // TenantTagRepo is the narrow port the tenant tagging service depends on. The production implementation // (NewTenantTagRepo) is dbgen backed and runs every method inside RunInTenantTx as the application role, // so the reads and the writes are RLS scoped to the org in the context. The test injects an in memory fake // so the whole service is exercised with no DB. type TenantTagRepo interface { // ListCandidatesForTagging returns the org's live candidates with the text the classifier reads, RLS // scoped to the org in ctx. The rows are ordered by id in SQL so iteration is byte stable. ListCandidatesForTagging(ctx context.Context) ([]dbgen.ListCandidatesForMissionTaggingRow, error) // ListRequirementsForTagging returns the org's live requirements with the classifier text, RLS scoped. ListRequirementsForTagging(ctx context.Context) ([]dbgen.ListRequirementsForMissionTaggingRow, error) // UpsertCandidateMissionTags writes the given candidate mission tags idempotently, all inside ONE // tenant transaction as the application role so RLS is exercised on every row. An empty slice is a no op. UpsertCandidateMissionTags(ctx context.Context, tags []dbgen.UpsertCandidateMissionTagParams) error // UpsertRequirementMissionTags writes the given requirement mission tags idempotently inside one tenant // transaction as the application role. An empty slice is a no op. UpsertRequirementMissionTags(ctx context.Context, tags []dbgen.UpsertRequirementMissionTagParams) error } // TenantTagService tags a tenant's candidates and requirements with structured mission areas. The repo and // the prebuilt AliasIndex are injected so the service is pure logic over its inputs and needs no DB to test. type TenantTagService struct { repo TenantTagRepo idx missionclass.AliasIndex } // NewTenantTagService builds the tenant tagging service from a repo and a prebuilt AliasIndex. The index is // built ONCE by the caller from the global alias pairs (see BuildAliasIndex) and shared across orgs, so the // expensive alias compile happens one time per backfill run, not once per org. func NewTenantTagService(repo TenantTagRepo, idx missionclass.AliasIndex) *TenantTagService { return &TenantTagService{repo: repo, idx: idx} } // TenantTagResult is the counted outcome of tagging one org. Every count is derived from rows actually // classified and written, never fabricated. type TenantTagResult struct { CandidatesScanned int CandidateTags int RequirementsScanned int RequirementTags int } // TagCandidatesForOrg classifies every live candidate in the org in ctx and upserts a candidate_mission_tag // per Hit. The classify text is the headline followed by the candidate's skill names, so a tenant's SIGINT // analyst gets a SIGINT tag from its headline and a COMSEC tag from a COMSEC skill. The org id stamped on // each tag comes from the validated GUC context, never a caller argument. Idempotent and RLS scoped: the // whole write runs inside RunInTenantTx as the application role. func (s *TenantTagService) TagCandidatesForOrg(ctx context.Context) (TenantTagResult, error) { var res TenantTagResult orgID, err := orgUUIDFromContext(ctx) if err != nil { return res, err } rows, err := s.repo.ListCandidatesForTagging(ctx) if err != nil { return res, fmt.Errorf("federalsignal: list candidates for tagging: %w", err) } var tags []dbgen.UpsertCandidateMissionTagParams for _, r := range rows { res.CandidatesScanned++ text := joinText(r.Headline, r.SkillText) for _, h := range missionclass.Classify(text, "", "", s.idx) { tags = append(tags, dbgen.UpsertCandidateMissionTagParams{ OrganizationID: orgID, CandidateID: r.CandidateID, MissionCode: h.Code, Mechanism: h.Mechanism, Evidence: h.Evidence, }) } } if err := s.repo.UpsertCandidateMissionTags(ctx, tags); err != nil { return res, fmt.Errorf("federalsignal: upsert candidate mission tags: %w", err) } res.CandidateTags = len(tags) return res, nil } // TagRequirementsForOrg classifies every live requirement in the org in ctx and upserts a // requirement_mission_tag per Hit. The classify text is the title, the labor category, and the required // skill names. Same isolation guarantees as the candidate path: the org id comes from the GUC context and // the write runs inside RunInTenantTx as the application role. Idempotent. func (s *TenantTagService) TagRequirementsForOrg(ctx context.Context) (TenantTagResult, error) { var res TenantTagResult orgID, err := orgUUIDFromContext(ctx) if err != nil { return res, err } rows, err := s.repo.ListRequirementsForTagging(ctx) if err != nil { return res, fmt.Errorf("federalsignal: list requirements for tagging: %w", err) } var tags []dbgen.UpsertRequirementMissionTagParams for _, r := range rows { res.RequirementsScanned++ text := joinText(r.Title, r.LaborCategory, r.SkillText) for _, h := range missionclass.Classify(text, "", "", s.idx) { tags = append(tags, dbgen.UpsertRequirementMissionTagParams{ OrganizationID: orgID, RequirementID: r.RequirementID, MissionCode: h.Code, Mechanism: h.Mechanism, Evidence: h.Evidence, }) } } if err := s.repo.UpsertRequirementMissionTags(ctx, tags); err != nil { return res, fmt.Errorf("federalsignal: upsert requirement mission tags: %w", err) } res.RequirementTags = len(tags) return res, nil } // orgUUIDFromContext reads the validated org id from the tenant context and parses it. A missing or // malformed org is a hard error: tagging refuses to run unscoped, mirroring RunInTenantTx fail closed. func orgUUIDFromContext(ctx context.Context) (uuid.UUID, error) { raw, ok := tenant.OrgFromContext(ctx) if !ok { return uuid.Nil, tenant.ErrNoTenant } id, err := uuid.Parse(raw) if err != nil { return uuid.Nil, fmt.Errorf("federalsignal: org id in context is not a uuid: %w", err) } return id, nil } // joinText joins non empty parts with single spaces into the one blob the classifier reads. Empty parts // are dropped so a missing headline or labor category never injects stray whitespace that the word boundary // matcher would have to absorb. func joinText(parts ...string) string { out := "" for _, p := range parts { if p == "" { continue } if out != "" { out += " " } out += p } return out }