package matching import ( "context" "encoding/json" "errors" "fmt" "sort" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" "github.com/missionworkforce/missionmatch/internal/modules/matching/intelligence" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/extraction" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ErrNoDatabase is returned when a DB-backed operation is attempted but no pool is configured. var ( ErrNoDatabase = errors.New("matching: no database configured") ErrNotFound = errors.New("matching: not found") ErrBadRequest = errors.New("matching: bad request") ErrNoExtractor = errors.New("matching: no extractor configured") ) const defaultMaxCandidates = 500 // Service is the slate-loop application service. It runs every DB operation inside a tenant // transaction (RLS-scoped) and delegates scoring to the pure engine. type Service struct { db *tenant.DB matcher engine.SkillMatcher // injected semantic skill matcher; may be nil (exact-only) aud *audit.Writer // AEDT audit trail; may be nil (tests skip audit) extractor extraction.Extractor // Mission Check resume/JD extractor; may be nil (Mission Check then 503) clock func() time.Time // injectable for deterministic tests } // NewService builds the service. db may be nil (DB routes then return ErrNoDatabase). aud may be nil. func NewService(db *tenant.DB, matcher engine.SkillMatcher, aud *audit.Writer) *Service { return &Service{db: db, matcher: matcher, aud: aud, clock: time.Now} } // WithExtractor injects the Mission Check structured extractor and returns the service for chaining. It // is a separate setter so the NewService signature (and its existing callers, including the integration // tests) stay unchanged. A service with no extractor returns ErrNoExtractor on the Mission Check paths. func (s *Service) WithExtractor(ext extraction.Extractor) *Service { s.extractor = ext return s } // auditOnTx records an AEDT event on the caller's tenant tx if an audit writer is configured. The // actor is the validated session principal; demographics are NEVER carried in an audit event. It // returns the write error so the caller fails closed: an AEDT score or decision that cannot be // recorded rolls back rather than being served unaudited (the audit package contract). func (s *Service) auditOnTx(ctx context.Context, tx pgx.Tx, e audit.Event) error { if s.aud == nil { return nil } if p, ok := tenant.UserFromContext(ctx); ok { if id, err := uuid.Parse(p.UserID); err == nil { e.ActorUserID = &id } } return s.aud.OnTx(ctx, tx, e) } // servedScoreEvent builds the aedt.score.served event for a single served candidate score. It carries // the candidate, the tool, the score, and the band so the compliance pool can reconstruct a screening // stage adverse impact analysis by joining to eeo, and NOTHING about demographics. func servedScoreEvent(tool string, candidateID uuid.UUID, res engine.MatchResult) audit.Event { payload, _ := json.Marshal(map[string]any{ "tool": tool, "score": res.Total.StringFixed(2), "band": res.Band.Code(), }) return audit.Event{Action: "aedt.score.served", EntityType: "candidate", EntityID: candidateID.String(), NewValues: payload} } // actorID returns the validated session user id, or nil when there is no principal (background jobs, // tests). It is the evaluated_by/decided_by actor stamped on a persisted artifact. func actorID(ctx context.Context) *uuid.UUID { if p, ok := tenant.UserFromContext(ctx); ok { if id, err := uuid.Parse(p.UserID); err == nil { return &id } } return nil } // persistEvaluationOnTx upserts the latest screenMATCH qualification for one candidate against one // requirement on the caller's tenant tx, so missionMATCH reads a stored evaluation rather than // recompute on read. It is a no op when no DB is configured. The served score is audited separately; // this is the readable artifact. It runs inside the same transaction as the score and its audit, so a // failed write rolls the screen back rather than serving a score the rollup will never see. func (s *Service) persistEvaluationOnTx(ctx context.Context, tx pgx.Tx, orgID, candidateID, requirementID uuid.UUID, res engine.MatchResult) error { params, err := ToCandidateEvaluationParams(orgID, candidateID, requirementID, res, actorID(ctx)) if err != nil { return err } return dbgen.New(tx).UpsertCandidateEvaluation(ctx, params) } func (s *Service) orgID(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("matching: invalid org id in context: %w", err) } return id, nil } // ScreenCandidate scores one candidate against one requirement (screenMATCH / POST /match). Read-only. func (s *Service) ScreenCandidate(ctx context.Context, candidateID, requirementID uuid.UUID) (engine.MatchResult, error) { if s.db == nil { return engine.MatchResult{}, ErrNoDatabase } orgID, err := s.orgID(ctx) if err != nil { return engine.MatchResult{}, err } asOf := s.clock() var res engine.MatchResult err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) er, err := loadRequirement(ctx, q, requirementID) if err != nil { return err } c, err := q.GetCandidate(ctx, candidateID) if err != nil { return fmt.Errorf("get candidate: %w", err) } ec, err := loadCandidate(ctx, q, c, asOf) if err != nil { return err } res = engine.ScoreCandidate(ctx, ec, er, s.matcher) // A focused single candidate qualification is a screening stage AEDT output. Record it as a // served score (candidate, tool, score, band, no demographics) so adverse impact at the // screening stage stays visible even before any slate decision exists. Fail closed if it cannot // be recorded. if e := s.auditOnTx(ctx, tx, servedScoreEvent("screenMATCH.match", candidateID, res)); e != nil { return e } // Persist the qualification (upsert latest per pair) so missionMATCH reads the stored evaluation // rather than recompute it on read. return s.persistEvaluationOnTx(ctx, tx, orgID, candidateID, requirementID, res) }) return res, err } // GenerateSlate scores every candidate against a requirement, ranks them, and persists the slate // plus the frozen decision records (slateMATCH / POST /slates/generate). One atomic transaction. func (s *Service) GenerateSlate(ctx context.Context, in GenerateSlateInput) (SlateResult, error) { if s.db == nil { return SlateResult{}, ErrNoDatabase } if in.Name == "" { return SlateResult{}, errors.New("matching: slate name is required") } if in.MaxCandidates <= 0 { in.MaxCandidates = defaultMaxCandidates } orgID, err := s.orgID(ctx) if err != nil { return SlateResult{}, err } asOf := s.clock() var out SlateResult err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var e error out, e = generateSlate(ctx, dbgen.New(tx), orgID, in, s.matcher, asOf) if e != nil { return e } payload, _ := json.Marshal(map[string]any{"requirement_id": in.RequirementID.String(), "members": len(out.Members)}) return s.auditOnTx(ctx, tx, audit.Event{Action: "aedt.slate.generated", EntityType: "slate", EntityID: out.SlateID.String(), NewValues: payload}) }) return out, err } // DecideSlateMember records the human approve or reject of a slate member. This is the human in the // loop control: the engine never decides; a person with slate authority does, and the action is // recorded with the actor and the time, plus an AEDT audit event. decision must be approved or // rejected (pending is the un decided default and cannot be set here). func (s *Service) DecideSlateMember(ctx context.Context, slateID, candidateID uuid.UUID, decision, note string) error { if s.db == nil { return ErrNoDatabase } if decision != "approved" && decision != "rejected" { return ErrBadRequest } p, ok := tenant.UserFromContext(ctx) if !ok { return tenant.ErrNoTenant } decidedBy, err := uuid.Parse(p.UserID) if err != nil { return tenant.ErrNoTenant } var notePtr *string if n := strings.TrimSpace(note); n != "" { notePtr = &n } return s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) row, e := q.SetSlateMemberDecision(ctx, dbgen.SetSlateMemberDecisionParams{ SlateID: slateID, CandidateID: candidateID, Decision: decision, DecidedBy: &decidedBy, DecisionNote: notePtr, }) if errors.Is(e, pgx.ErrNoRows) { return ErrNotFound } if e != nil { return e } // The note is part of the human in the loop record but is NOT echoed into the audit payload: a // reviewer note can carry candidate PII, which never belongs in an audit event. payload, _ := json.Marshal(map[string]any{"decision": decision, "band": row.Band, "has_note": notePtr != nil}) return s.auditOnTx(ctx, tx, audit.Event{Action: "aedt.slate_member.decided", EntityType: "candidate", EntityID: candidateID.String(), NewValues: payload}) }) } // SlateMemberTrace returns the persisted per component trace, rationale, and gap summary for a frozen // decision, the audit and candidate facing explanation of what was assessed and why. func (s *Service) SlateMemberTrace(ctx context.Context, slateID, candidateID uuid.UUID) (dbgen.SlateMember, error) { if s.db == nil { return dbgen.SlateMember{}, ErrNoDatabase } var m dbgen.SlateMember err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { row, e := dbgen.New(tx).GetSlateMember(ctx, dbgen.GetSlateMemberParams{SlateID: slateID, CandidateID: candidateID}) if errors.Is(e, pgx.ErrNoRows) { return ErrNotFound } if e != nil { return e } m = row return nil }) return m, err } // ListCandidates / ListRequirements are the read endpoints (GET /candidates, /requirements). func (s *Service) ListCandidates(ctx context.Context, limit, offset int32) ([]dbgen.Candidate, error) { if s.db == nil { return nil, ErrNoDatabase } var out []dbgen.Candidate err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var e error out, e = dbgen.New(tx).ListCandidates(ctx, dbgen.ListCandidatesParams{Limit: limit, Offset: offset}) return e }) return out, err } func (s *Service) ListRequirements(ctx context.Context, limit, offset int32) ([]dbgen.Requirement, error) { if s.db == nil { return nil, ErrNoDatabase } var out []dbgen.Requirement err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var e error out, e = dbgen.New(tx).ListRequirements(ctx, dbgen.ListRequirementsParams{Limit: limit, Offset: offset}) return e }) return out, err } // GetCandidate returns one candidate by id, scoped to the caller's org by RLS. A missing id or one in // another org both surface as ErrNotFound: RLS makes the two indistinguishable, which is the intended // privacy floor (no cross org existence oracle). The by id read backs a deep linked evaluation, so the // page no longer depends on the candidate being inside the first list page. func (s *Service) GetCandidate(ctx context.Context, id uuid.UUID) (dbgen.Candidate, error) { if s.db == nil { return dbgen.Candidate{}, ErrNoDatabase } var out dbgen.Candidate err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { c, e := dbgen.New(tx).GetCandidate(ctx, id) if errors.Is(e, pgx.ErrNoRows) { return ErrNotFound } if e != nil { return e } out = c return nil }) return out, err } // GetRequirement returns one requirement by id, scoped to the caller's org by RLS. Missing and cross // org both surface as ErrNotFound, mirroring GetCandidate. func (s *Service) GetRequirement(ctx context.Context, id uuid.UUID) (dbgen.Requirement, error) { if s.db == nil { return dbgen.Requirement{}, ErrNoDatabase } var out dbgen.Requirement err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { rq, e := dbgen.New(tx).GetRequirement(ctx, id) if errors.Is(e, pgx.ErrNoRows) { return ErrNotFound } if e != nil { return e } out = rq return nil }) return out, err } // generateSlate is the pure orchestration over a Querier, testable with a fake, no live DB. func generateSlate(ctx context.Context, q dbgen.Querier, orgID uuid.UUID, in GenerateSlateInput, matcher engine.SkillMatcher, asOf time.Time) (SlateResult, error) { er, err := loadRequirement(ctx, q, in.RequirementID) if err != nil { return SlateResult{}, err } cands, err := q.ListCandidates(ctx, dbgen.ListCandidatesParams{Limit: in.MaxCandidates, Offset: 0}) if err != nil { return SlateResult{}, fmt.Errorf("list candidates: %w", err) } type scored struct { id uuid.UUID res engine.MatchResult } results := make([]scored, 0, len(cands)) for _, c := range cands { ec, err := loadCandidate(ctx, q, c, asOf) if err != nil { return SlateResult{}, err } results = append(results, scored{id: c.ID, res: engine.ScoreCandidate(ctx, ec, er, matcher)}) } // Deterministic ranking: total score descending, candidate id ascending as the unique tiebreaker. sort.SliceStable(results, func(i, j int) bool { if d := results[i].res.Total.Cmp(results[j].res.Total); d != 0 { return d > 0 } return results[i].id.String() < results[j].id.String() }) slate, err := q.CreateSlate(ctx, dbgen.CreateSlateParams{ OrganizationID: orgID, RequirementID: in.RequirementID, Name: in.Name, Status: "draft", CreatedBy: in.CreatedBy, }) if err != nil { return SlateResult{}, fmt.Errorf("create slate: %w", err) } members := make([]SlateMemberView, 0, len(results)) for i, sc := range results { rank := int32(i + 1) params, err := ToSlateMemberParams(orgID, slate.ID, sc.id, sc.res, &rank) if err != nil { return SlateResult{}, err } if _, err := q.AddSlateMember(ctx, params); err != nil { return SlateResult{}, fmt.Errorf("add slate member: %w", err) } members = append(members, slateMemberView(sc.id.String(), int(rank), sc.res)) } return SlateResult{SlateID: slate.ID, RequirementID: in.RequirementID, Name: in.Name, Members: members}, nil } func loadRequirement(ctx context.Context, q dbgen.Querier, reqID uuid.UUID) (engine.Requirement, error) { r, err := q.GetRequirement(ctx, reqID) if err != nil { return engine.Requirement{}, fmt.Errorf("get requirement: %w", err) } return reqFromRow(ctx, q, r) } func reqFromRow(ctx context.Context, q dbgen.Querier, r dbgen.Requirement) (engine.Requirement, error) { rs, err := q.ListRequiredSkills(ctx, r.ID) if err != nil { return engine.Requirement{}, fmt.Errorf("list required skills: %w", err) } rc, err := q.ListRequiredCertifications(ctx, r.ID) if err != nil { return engine.Requirement{}, fmt.Errorf("list required certifications: %w", err) } return ToEngineRequirement(r, rs, rc) } // loadCandidateByID loads a candidate, its sub-lists, and the engine input in one place. It returns // the DB row (for clearance dates), the engine candidate, and the customer agencies. func loadCandidateByID(ctx context.Context, q dbgen.Querier, id uuid.UUID, asOf time.Time) (dbgen.Candidate, engine.Candidate, []string, error) { c, err := q.GetCandidate(ctx, id) if err != nil { return dbgen.Candidate{}, engine.Candidate{}, nil, fmt.Errorf("get candidate: %w", err) } skills, err := q.ListCandidateSkills(ctx, id) if err != nil { return dbgen.Candidate{}, engine.Candidate{}, nil, fmt.Errorf("list candidate skills: %w", err) } certs, err := q.ListCandidateCertifications(ctx, id) if err != nil { return dbgen.Candidate{}, engine.Candidate{}, nil, fmt.Errorf("list candidate certifications: %w", err) } cust, err := q.ListCandidateCustomerExperience(ctx, id) if err != nil { return dbgen.Candidate{}, engine.Candidate{}, nil, fmt.Errorf("list candidate customer experience: %w", err) } ec, err := ToEngineCandidate(c, skills, certs, cust, asOf) if err != nil { return dbgen.Candidate{}, engine.Candidate{}, nil, err } return c, ec, cust, nil } // Screen returns the full screenMATCHâ„¢ intelligence view for one candidate vs one requirement, // the explainable score plus the Mission Readiness panels. Read only. func (s *Service) Screen(ctx context.Context, candidateID, requirementID uuid.UUID) (ScreenView, error) { if s.db == nil { return ScreenView{}, ErrNoDatabase } orgID, err := s.orgID(ctx) if err != nil { return ScreenView{}, err } asOf := s.clock() var view ScreenView err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) er, err := loadRequirement(ctx, q, requirementID) if err != nil { return err } c, ec, cust, err := loadCandidateByID(ctx, q, candidateID, asOf) if err != nil { return err } res := engine.ScoreCandidate(ctx, ec, er, s.matcher) view = assembleScreenView(ec, er, res, ClearanceFactsFrom(c, cust), asOf) if e := s.auditOnTx(ctx, tx, servedScoreEvent("screenMATCH.screen", candidateID, res)); e != nil { return e } // Persist the qualification (upsert latest per pair) so missionMATCH reads the stored evaluation. return s.persistEvaluationOnTx(ctx, tx, orgID, candidateID, requirementID, res) }) return view, err } // RankCandidates scores EVERY candidate in the org against one requirement and returns them ranked // (Mission Readiness descending), the Candidate Screening Queue (Screen 1). Read-only: no slate is // persisted (use GenerateSlate to freeze a slate). func (s *Service) RankCandidates(ctx context.Context, requirementID uuid.UUID) ([]RankedCandidateView, error) { if s.db == nil { return nil, ErrNoDatabase } asOf := s.clock() var out []RankedCandidateView err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) er, err := loadRequirement(ctx, q, requirementID) if err != nil { return err } cands, err := q.ListCandidates(ctx, dbgen.ListCandidatesParams{Limit: 500, Offset: 0}) if err != nil { return fmt.Errorf("list candidates: %w", err) } type scored struct { view RankedCandidateView total decimal.Decimal } ranked := make([]scored, 0, len(cands)) for _, c := range cands { ec, err := loadCandidate(ctx, q, c, asOf) if err != nil { return err } res := engine.ScoreCandidate(ctx, ec, er, s.matcher) // The Candidate Screening Queue scores everyone for a requirement, a screening stage AEDT // output. Record each as a served score so the screening stage adverse impact denominator is // complete, not biased toward the focused single candidate screens. if e := s.auditOnTx(ctx, tx, servedScoreEvent("screenMATCH.rank", c.ID, res)); e != nil { return e } ranked = append(ranked, scored{view: rankedCandidateView(c, res), total: res.Total}) } sort.SliceStable(ranked, func(i, j int) bool { if d := ranked[i].total.Cmp(ranked[j].total); d != 0 { return d > 0 } return ranked[i].view.FullName < ranked[j].view.FullName }) out = make([]RankedCandidateView, len(ranked)) for i, sc := range ranked { out[i] = sc.view } return nil }) return out, err } // OpportunitiesForCandidate scores one candidate against every open requirement in the org and // returns the best fits, ranked, the Opportunity Fit Engineâ„¢ ("best opportunities for this person"). func (s *Service) OpportunitiesForCandidate(ctx context.Context, candidateID uuid.UUID, limit int) ([]OpportunityFitView, error) { if s.db == nil { return nil, ErrNoDatabase } if limit <= 0 || limit > 100 { limit = 10 } asOf := s.clock() var out []OpportunityFitView err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) _, ec, _, err := loadCandidateByID(ctx, q, candidateID, asOf) if err != nil { return err } out, err = s.rankOpenOpportunities(ctx, tx, ec, limit, func(res engine.MatchResult) error { // Scoring this person against each opportunity is a screening stage AEDT output; record each // served score so the screening stage analysis is complete. return s.auditOnTx(ctx, tx, servedScoreEvent("screenMATCH.opportunities", candidateID, res)) }) return err }) return out, err } // rankOpenOpportunities scores a ready engine.Candidate against every OPEN requirement, calls auditFn // for each served score (so the saved path and the ephemeral Mission Check path each record under their // own AEDT action), ranks total descending with the requirement id as the stable tiebreak, and // truncates to limit. It is the single ranking path shared by OpportunitiesForCandidate and Mission // Check Mode A, so an unsaved resume and a saved candidate with identical fields rank identically. func (s *Service) rankOpenOpportunities(ctx context.Context, tx pgx.Tx, ec engine.Candidate, limit int, auditFn func(res engine.MatchResult) error) ([]OpportunityFitView, error) { q := dbgen.New(tx) reqs, err := q.ListOpenRequirements(ctx, dbgen.ListOpenRequirementsParams{Limit: 500, Offset: 0}) if err != nil { return nil, fmt.Errorf("list open requirements: %w", err) } type scored struct { view OpportunityFitView total decimal.Decimal } ranked := make([]scored, 0, len(reqs)) for _, rr := range reqs { er, err := reqFromRow(ctx, q, rr) if err != nil { return nil, err } res := engine.ScoreCandidate(ctx, ec, er, s.matcher) if err := auditFn(res); err != nil { return nil, err } ranked = append(ranked, scored{ view: OpportunityFitView{ RequirementID: rr.ID, Title: rr.Title, CustomerAgency: deref(rr.CustomerAgency), Score: res.Total.StringFixed(2), Band: res.Band.Code(), Status: intelligence.MissionStatusLabel(res.Band), }, total: res.Total, }) } sort.SliceStable(ranked, func(i, j int) bool { if d := ranked[i].total.Cmp(ranked[j].total); d != 0 { return d > 0 } return ranked[i].view.RequirementID.String() < ranked[j].view.RequirementID.String() }) if len(ranked) > limit { ranked = ranked[:limit] } out := make([]OpportunityFitView, len(ranked)) for i, sc := range ranked { out[i] = sc.view } return out, nil } func loadCandidate(ctx context.Context, q dbgen.Querier, c dbgen.Candidate, asOf time.Time) (engine.Candidate, error) { skills, err := q.ListCandidateSkills(ctx, c.ID) if err != nil { return engine.Candidate{}, fmt.Errorf("list candidate skills: %w", err) } certs, err := q.ListCandidateCertifications(ctx, c.ID) if err != nil { return engine.Candidate{}, fmt.Errorf("list candidate certifications: %w", err) } cust, err := q.ListCandidateCustomerExperience(ctx, c.ID) if err != nil { return engine.Candidate{}, fmt.Errorf("list candidate customer experience: %w", err) } return ToEngineCandidate(c, skills, certs, cust, asOf) }