// Package governance is the AEDT governance surface: the model version registry, the org AEDT // inventory, the candidate notice and disclosure record, the retention sweep, and the audit read. It // runs on the app pool under RLS and never touches the eeo schema (that is the compliance pool's job). package governance import ( "context" "encoding/json" "errors" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ErrNoDatabase is returned when no app pool is configured. var ErrNoDatabase = errors.New("governance: no database configured") // Service implements the AEDT governance operations. type Service struct { db *tenant.DB aud *audit.Writer retentionDays int } // NewService builds the service. retentionDays is the configured retention window for identifiable // screening artifacts after a decision. func NewService(db *tenant.DB, aud *audit.Writer, retentionDays int) *Service { return &Service{db: db, aud: aud, retentionDays: retentionDays} } func (s *Service) ok() error { if s.db == nil || s.db.Pool == nil { return ErrNoDatabase } return nil } // ListModelVersions returns the scoring model registry: each version with the weights and band cuts it // used, so a frozen decision is reproducible against the version that produced it. func (s *Service) ListModelVersions(ctx context.Context) ([]dbgen.AedtModelVersion, error) { if err := s.ok(); err != nil { return nil, err } var out []dbgen.AedtModelVersion err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var e error out, e = dbgen.New(tx).ListModelVersions(ctx) return e }) return out, err } // ListInventory returns the org's register of automated employment decision tools in use. func (s *Service) ListInventory(ctx context.Context) ([]dbgen.AedtInventory, error) { if err := s.ok(); err != nil { return nil, err } var out []dbgen.AedtInventory err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var e error out, e = dbgen.New(tx).ListAedtInventory(ctx) return e }) return out, err } // InventoryInput registers or updates one AEDT tool in the org inventory. type InventoryInput struct { ToolKey string `json:"tool_key"` DisplayName string `json:"display_name"` Purpose string `json:"purpose"` ModelVersion string `json:"model_version"` } // UpsertInventory records a tool in the org inventory and audits the change. func (s *Service) UpsertInventory(ctx context.Context, in InventoryInput) (dbgen.AedtInventory, error) { if err := s.ok(); err != nil { return dbgen.AedtInventory{}, err } var row dbgen.AedtInventory err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var mv *string if in.ModelVersion != "" { mv = &in.ModelVersion } r, e := dbgen.New(tx).UpsertAedtInventory(ctx, dbgen.UpsertAedtInventoryParams{ ToolKey: in.ToolKey, DisplayName: in.DisplayName, Purpose: in.Purpose, ModelVersion: mv, }) if e != nil { return e } row = r s.audit(ctx, tx, audit.Event{Action: "aedt.inventory.recorded", EntityType: "aedt_tool", EntityID: in.ToolKey}) return nil }) return row, err } // NoticeInput records that a candidate was given the AEDT notice and disclosure. type NoticeInput struct { NoticeVersion string `json:"notice_version"` DataCategories []string `json:"data_categories"` } // RecordNotice writes the candidate notice and disclosure record on the tenant tx and audits it. The // candidate consent itself lives in the unreadable eeo schema; this is the public schema disclosure // that the notice was provided. func (s *Service) RecordNotice(ctx context.Context, candidateID uuid.UUID, in NoticeInput) (dbgen.AedtCandidateNotice, error) { if err := s.ok(); err != nil { return dbgen.AedtCandidateNotice{}, err } cats := in.DataCategories if len(cats) == 0 { cats = defaultDataCategories } catJSON, _ := json.Marshal(cats) var row dbgen.AedtCandidateNotice err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { r, e := dbgen.New(tx).InsertCandidateNotice(ctx, dbgen.InsertCandidateNoticeParams{ CandidateID: candidateID, NoticeVersion: in.NoticeVersion, DataCategories: catJSON, RetentionDays: int32(s.retentionDays), DisclosedBy: actorPtr(ctx), }) if e != nil { return e } row = r s.audit(ctx, tx, audit.Event{Action: "aedt.candidate.notice_disclosed", EntityType: "candidate", EntityID: candidateID.String()}) return nil }) return row, err } // RunRetention anonymizes the org's candidates whose most recent decision is older than the retention // window, wiping PII while keeping the de identified outcomes. It returns the number anonymized and // audits each. This is the on demand, RLS scoped sweep; a platform wide scheduled sweep needs a // privileged cross org enumeration and is a separate, flagged enhancement. func (s *Service) RunRetention(ctx context.Context) (int, error) { if err := s.ok(); err != nil { return 0, err } count := 0 err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) due, e := q.RetentionDueCandidates(ctx, int32(s.retentionDays)) if e != nil { return e } for _, id := range due { rows, e := q.AnonymizeCandidate(ctx, id) if e != nil { return e } if rows > 0 { count++ // Purge the qualification child data too so the retention sweep leaves no child PII, // matching the on demand erasure. The frozen slate_members outcomes are untouched. for _, stmt := range []string{ `DELETE FROM candidate_skills WHERE candidate_id = $1`, `DELETE FROM candidate_certifications WHERE candidate_id = $1`, `DELETE FROM candidate_customer_experience WHERE candidate_id = $1`, } { if _, e := tx.Exec(ctx, stmt, id); e != nil { return e } } s.audit(ctx, tx, audit.Event{Action: "retention.candidate.anonymized", EntityType: "candidate", EntityID: id.String()}) } } return nil }) return count, err } // ListAudit returns the org's audit trail (org isolated through the GUC bound query, not a Go filter). func (s *Service) ListAudit(ctx context.Context, limit, offset int32) ([]dbgen.AuditLog, error) { if err := s.ok(); err != nil { return nil, err } if limit <= 0 || limit > 500 { limit = 100 } var out []dbgen.AuditLog err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var e error out, e = dbgen.New(tx).ListAuditLogsForOrg(ctx, dbgen.ListAuditLogsForOrgParams{Limit: limit, Offset: offset}) return e }) return out, err } func (s *Service) audit(ctx context.Context, tx pgx.Tx, e audit.Event) { if s.aud == nil { return } e.ActorUserID = actorPtr(ctx) _ = s.aud.OnTx(ctx, tx, e) } func actorPtr(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 } var defaultDataCategories = []string{ "clearance self reported indicator", "skills", "certifications", "customer agency experience", "years of experience", "work location and availability", }