package ai // curation_apply.go is the WS8 Phase 4 TWO STEP human in the loop apply path (LIVE). It is the only place a // curation proposal turns into a curated ontology edge, and it does so in two distinct, separately authorized // human actions, never one click and never automatically: // // 1. APPROVE (the first human step). A compliance or operator user approves a pending proposal. The proposal // row is moved to approved with the approver and approved_at recorded, and for a substitutes_for_edge a // skill_relationships row is INSERTED with verified_at NULL. That edge is STAGED and INERT: the resolver // loads only verified_at IS NOT NULL edges (aliasresolver.go), so a staged edge changes NO score. The // proposal approved event is audited through System. // // 2. VERIFY (a SECOND, distinct human step). A compliance or operator user verifies the staged edge, setting // verified_at and the verifier. Only this single write makes the edge live: from now on the resolver // reads it and it changes FUTURE scores (never an already served and audited score, and never the engine // math, which is byte identical, only the resolver reference set grew). The edge added event is audited // through System carrying the from, the to, the relation, the strength, and the verifier. // // Every write here goes through the MIGRATOR pool, never the app role, because curation_proposals and // skill_relationships are global migrator owned tables the app role can only SELECT. The actor on each step is // the validated session principal, never a client supplied id, and the two events are global (NULL org) // because a proposal and an ontology edge are global objects, not per tenant ones. import ( "context" "encoding/json" "errors" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" ) // Curation apply errors. They map to typed HTTP statuses at the handler so a double submit or a missing // proposal is a clear client error, not a 500. var ( // ErrProposalNotFound is returned when no proposal exists for the id. ErrProposalNotFound = errors.New("curation: proposal not found") // ErrProposalNotPending is returned when an approve targets a proposal that is not pending (already // approved, already rejected, or concurrently moved), so an approval never re fires. ErrProposalNotPending = errors.New("curation: proposal is not pending") // ErrProposalNotApproved is returned when a verify targets a proposal that is not approved yet, enforcing // that verify is genuinely the second step after approve. ErrProposalNotApproved = errors.New("curation: proposal is not approved") // ErrNotASubstitutionEdge is returned when an approve or verify targets a proposal whose kind is not a // substitutes_for_edge, because only that kind stages and verifies an ontology edge. The evidence only // kinds (reciprocity_review, recalibration_evidence) are not applied to the resolver. ErrNotASubstitutionEdge = errors.New("curation: proposal is not a substitutes_for_edge") // ErrNoStagedEdge is returned when a verify finds no staged edge to activate (the approve never ran, or // the edge is already verified). ErrNoStagedEdge = errors.New("curation: no staged edge to verify") // ErrNoActor is returned when the request has no validated session principal to record as the actor. ErrNoActor = errors.New("curation: no validated actor") // ErrNoMigratorPool is returned when the apply service has no migrator pool configured, so the governance // write surface reports unavailable rather than silently using the app role. ErrNoMigratorPool = errors.New("curation: migrator pool not configured") // ErrVerifierMustDifferFromApprover is returned when separation of duties is enforced and the same user // who approved a proposal tries to verify its staged edge. It makes the two person control real rather // than procedural. A single operator pilot may turn enforcement off in config to self verify. ErrVerifierMustDifferFromApprover = errors.New("curation: the verifier must differ from the approver") ) // CurationApplyService runs the two step apply on the migrator pool. It holds the migrator pool directly (the // global write seam) and the audit writer (System events). It is constructed only when a migrator DSN is // present; a nil pool makes every action return ErrNoMigratorPool so the surface fails closed. type CurationApplyService struct { migrator *pgxpool.Pool audit *audit.Writer enforceSeparation bool // when true, the verifier must differ from the approver (the two person control) } // NewCurationApplyService wires the apply service. migrator is the BYPASSRLS migrator pool (the only pool that // may write the global curation tables); auditW records the global proposal approved and edge added events. // enforceSeparation requires the verifier to differ from the approver (config MM_CURATION_SEPARATION_OF_DUTIES, // default true); a single operator pilot may pass false to self verify, an explicit audited choice. func NewCurationApplyService(migrator *pgxpool.Pool, auditW *audit.Writer, enforceSeparation bool) *CurationApplyService { return &CurationApplyService{migrator: migrator, audit: auditW, enforceSeparation: enforceSeparation} } // ProposalView is the read shape the /compliance surface lists: a pending proposal with its kind, the skill // pair (ids only, never PII), the proposed strength, and the evidence counts. The frontend renders the // approve and verify actions over this. type ProposalView struct { ID string `json:"id"` Kind string `json:"kind"` FromSkillID string `json:"from_skill_id"` ToSkillID string `json:"to_skill_id"` Relation string `json:"relation"` ProposedStrength string `json:"proposed_strength"` EvidenceObservations int `json:"evidence_observations"` EvidenceSuccesses int `json:"evidence_successes"` ModelVersion string `json:"model_version"` Status string `json:"status"` CreatedAt string `json:"created_at"` } // ListPending returns the proposals awaiting the first human step, read through the migrator pool. func (s *CurationApplyService) ListPending(ctx context.Context) ([]ProposalView, error) { if s == nil || s.migrator == nil { return nil, ErrNoMigratorPool } rows, err := dbgen.New(s.migrator).ListPendingProposals(ctx) if err != nil { return nil, err } out := make([]ProposalView, 0, len(rows)) for _, r := range rows { out = append(out, toProposalView(r)) } return out, nil } // Approve is the FIRST human step. It moves a pending substitutes_for_edge proposal to approved (recording the // approver and approved_at) and stages the INERT skill_relationships edge (verified_at NULL) in one migrator // transaction, so the proposal transition and the staged edge commit together. The staged edge is read by NO // score (the resolver ignores unverified edges), so this step changes nothing live yet. It audits // teaching.proposal.approved through System. actor is the validated session principal. func (s *CurationApplyService) Approve(ctx context.Context, proposalID, actor uuid.UUID) (ProposalView, error) { if s == nil || s.migrator == nil { return ProposalView{}, ErrNoMigratorPool } var view ProposalView err := pgx.BeginFunc(ctx, s.migrator, func(tx pgx.Tx) error { q := dbgen.New(tx) prop, gerr := q.GetProposal(ctx, proposalID) if errors.Is(gerr, pgx.ErrNoRows) { return ErrProposalNotFound } if gerr != nil { return gerr } if prop.Kind != kindSubstitutesForEdge { return ErrNotASubstitutionEdge } if prop.Status != "pending" { return ErrProposalNotPending } if prop.FromSkillID == nil || prop.ToSkillID == nil || prop.ProposedStrength == nil { return ErrNotASubstitutionEdge } approved, aerr := q.ApproveProposalTx(ctx, dbgen.ApproveProposalTxParams{ID: proposalID, Approver: &actor}) if errors.Is(aerr, pgx.ErrNoRows) { return ErrProposalNotPending // a concurrent approval won the race } if aerr != nil { return aerr } // Stage the INERT edge. verified_at stays NULL, so the resolver does not read it; a SECOND human action // verifies it later to make it live. ON CONFLICT keeps approve idempotent against an existing edge. if _, serr := q.StageSubstitutesForEdge(ctx, dbgen.StageSubstitutesForEdgeParams{ FromSkillID: *approved.FromSkillID, ToSkillID: *approved.ToSkillID, Strength: *approved.ProposedStrength, }); serr != nil { return serr } payload, merr := json.Marshal(map[string]any{ "from_skill_id": approved.FromSkillID.String(), "to_skill_id": approved.ToSkillID.String(), "relation": substitutionRelation, "staged": true, // staged and inert, not yet verified }) if merr != nil { return merr } if auderr := s.audit.System(ctx, audit.Event{ Action: "teaching.proposal.approved", ActorUserID: &actor, EntityType: "curation_proposal", EntityID: proposalID.String(), NewValues: payload, }); auderr != nil { return auderr } view = toProposalView(approved) return nil }) if err != nil { return ProposalView{}, err } return view, nil } // Verify is the SECOND, distinct human step. It activates the staged edge that an approval created by setting // verified_at and the verifier, ONLY when the proposal is approved and the edge is still unverified. Only this // write makes the edge live: the resolver now reads it and it changes FUTURE scores. The engine ModelVersion // stays v1.4.0, because the math is unchanged and only the resolver reference set grew (recorded in the model // card). It audits ontology.edge.added through System carrying the from, the to, the relation, the strength, // and the verifier. actor is the validated session principal, distinct from the approver in practice. func (s *CurationApplyService) Verify(ctx context.Context, proposalID, actor uuid.UUID) (ProposalView, error) { if s == nil || s.migrator == nil { return ProposalView{}, ErrNoMigratorPool } var view ProposalView err := pgx.BeginFunc(ctx, s.migrator, func(tx pgx.Tx) error { q := dbgen.New(tx) prop, gerr := q.GetProposal(ctx, proposalID) if errors.Is(gerr, pgx.ErrNoRows) { return ErrProposalNotFound } if gerr != nil { return gerr } if prop.Kind != kindSubstitutesForEdge { return ErrNotASubstitutionEdge } if prop.Status != "approved" { return ErrProposalNotApproved } // Separation of duties: the human who verifies an edge live must not be the human who approved it, so // the two person control is enforced, not merely procedural. A single operator pilot may turn this off // in config. The approver is recorded on the proposal, so no extra read is needed. if s.enforceSeparation && prop.Approver != nil && *prop.Approver == actor { return ErrVerifierMustDifferFromApprover } if prop.FromSkillID == nil || prop.ToSkillID == nil { return ErrNotASubstitutionEdge } // Find the staged edge for this pair and activate it. A double submit finds the edge already verified // (VerifyStagedEdge only matches verified_at IS NULL), so it returns no row and we report ErrNoStagedEdge, // keeping verify a single transition. edgeID, found, ferr := stagedEdgeID(ctx, tx, *prop.FromSkillID, *prop.ToSkillID) if ferr != nil { return ferr } if !found { return ErrNoStagedEdge } edge, verr := q.VerifyStagedEdge(ctx, dbgen.VerifyStagedEdgeParams{ID: edgeID, Verifier: &actor}) if errors.Is(verr, pgx.ErrNoRows) { return ErrNoStagedEdge // already verified by a concurrent action } if verr != nil { return verr } payload, merr := json.Marshal(map[string]any{ "from_skill_id": edge.FromSkillID.String(), "to_skill_id": edge.ToSkillID.String(), "relation": edge.Relation, "strength": edge.Strength.String(), "verifier": actor.String(), }) if merr != nil { return merr } if auderr := s.audit.System(ctx, audit.Event{ Action: "ontology.edge.added", ActorUserID: &actor, EntityType: "skill_relationship", EntityID: edge.ID.String(), NewValues: payload, }); auderr != nil { return auderr } view = toProposalView(prop) view.Status = "verified" // surface the live state to the caller; the proposal row itself stays approved return nil }) if err != nil { return ProposalView{}, err } return view, nil } // stagedEdgeID finds the unverified staged skill_relationships edge for a substitutes_for pair, so verify can // activate exactly that row. It reads through the same migrator tx. An edge that is already verified is not // returned (found false), so verify is a single transition. func stagedEdgeID(ctx context.Context, tx pgx.Tx, fromSkillID, toSkillID uuid.UUID) (uuid.UUID, bool, error) { var id uuid.UUID err := tx.QueryRow(ctx, `SELECT id FROM skill_relationships WHERE from_skill_id = $1 AND to_skill_id = $2 AND relation = 'substitutes_for' AND verified_at IS NULL`, fromSkillID, toSkillID).Scan(&id) if errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, false, nil } if err != nil { return uuid.Nil, false, err } return id, true, nil } // toProposalView maps a stored proposal to the read shape, with empty strings for the nullable id and strength // fields rather than a nil pointer, so the frontend renders a stable row. func toProposalView(p dbgen.CurationProposal) ProposalView { v := ProposalView{ ID: p.ID.String(), Kind: p.Kind, EvidenceObservations: int(p.EvidenceObservations), EvidenceSuccesses: int(p.EvidenceSuccesses), ModelVersion: p.ModelVersion, Status: p.Status, CreatedAt: p.CreatedAt.Format("2006-01-02"), } if p.FromSkillID != nil { v.FromSkillID = p.FromSkillID.String() } if p.ToSkillID != nil { v.ToSkillID = p.ToSkillID.String() } if p.Relation != nil { v.Relation = *p.Relation } if p.ProposedStrength != nil { v.ProposedStrength = p.ProposedStrength.String() } return v }