package ai // curation_handler.go mounts the WS8 Phase 4 curation surface on the /compliance routes, gated behind the // curation.curate capability (the compliance and operator tier, owner and admin). It exposes the two step // human in the loop gate as three endpoints: list the pending proposals, APPROVE one (the first human step, // which stages an INERT edge), and VERIFY one (the second, distinct human step, which activates the edge). The // handler is thin: it reads the validated session actor, never a client supplied id, and delegates every write // to the migrator pool backed apply service. import ( "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // TeachingDPNotice is the short, plain language notice the /compliance page can show next to the curation // proposals, so a compliance officer sees the privacy and governance posture in one sentence without opening // the model card. It states the two load bearing facts: the honest score invariant (two human steps before any // future score moves, and never an already served score or the engine math), and that the cross tenant // differential privacy path is OFF in tenant LIVE. It carries no PII and no client fact, only the posture. The // full detail lives in docs/MODEL_CARD_TEACHING.md. const TeachingDPNotice = "The teaching loop learns abstract reviewer craft from this tenant only. " + "It never changes a weight, a band cut, the semantic threshold, or any already served score. " + "A curated skill edge changes only FUTURE scores, and only after two separate humans approve then verify it. " + "Cross tenant differential privacy aggregation is OFF and stays off until a real cohort of multiple live tenants exists and a privacy budget is signed off. " + "See the teaching model card for the full privacy and governance detail." // CurationHandler serves the curation proposal surface. svc may be nil (the apply service is unavailable when // no migrator pool is configured), in which case every endpoint reports the surface unavailable rather than // panicking, the graceful posture every module here holds. type CurationHandler struct{ svc *CurationApplyService } // NewCurationHandler builds the handler. func NewCurationHandler(svc *CurationApplyService) *CurationHandler { return &CurationHandler{svc: svc} } // Routes mounts the curation endpoints under /compliance, each gated on the curation.curate capability. The // approve and verify actions are two SEPARATE POST endpoints so the two human steps are two distinct, // separately auditable requests, never one combined call. func (h *CurationHandler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapCurationCurate)).Get("/compliance/curation/notice", h.notice) r.With(security.RequireCapability(security.CapCurationCurate)).Get("/compliance/curation/proposals", h.listPending) r.With(security.RequireCapability(security.CapCurationCurate)).Post("/compliance/curation/proposals/{id}/approve", h.approve) r.With(security.RequireCapability(security.CapCurationCurate)).Post("/compliance/curation/proposals/{id}/verify", h.verify) } // notice returns the short teaching loop privacy and governance notice the /compliance page shows. It is a // static, contents free posture statement, so it never touches the apply service or any tenant data. func (h *CurationHandler) notice(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"notice": TeachingDPNotice}) } func (h *CurationHandler) listPending(w http.ResponseWriter, r *http.Request) { if h.svc == nil { writeErr(w, http.StatusServiceUnavailable, "curation_unavailable", "the curation surface is not configured") return } views, err := h.svc.ListPending(r.Context()) if err != nil { mapCurationErr(w, err) return } writeJSON(w, http.StatusOK, views) } func (h *CurationHandler) approve(w http.ResponseWriter, r *http.Request) { h.act(w, r, func(id, actor uuid.UUID) (ProposalView, error) { return h.svc.Approve(r.Context(), id, actor) }) } func (h *CurationHandler) verify(w http.ResponseWriter, r *http.Request) { h.act(w, r, func(id, actor uuid.UUID) (ProposalView, error) { return h.svc.Verify(r.Context(), id, actor) }) } // act is the shared shell for approve and verify: it checks the surface is configured, parses the proposal id // from the path, resolves the validated session actor, runs the supplied step, and maps the result or the // typed error. The actor is read from the session principal, never the request body. func (h *CurationHandler) act(w http.ResponseWriter, r *http.Request, step func(id, actor uuid.UUID) (ProposalView, error)) { if h.svc == nil { writeErr(w, http.StatusServiceUnavailable, "curation_unavailable", "the curation surface is not configured") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid proposal id") return } p, ok := tenant.UserFromContext(r.Context()) if !ok { writeErr(w, http.StatusForbidden, "no_actor", "no validated actor") return } actor, err := uuid.Parse(p.UserID) if err != nil { writeErr(w, http.StatusForbidden, "no_actor", "no validated actor") return } view, err := step(id, actor) if err != nil { mapCurationErr(w, err) return } writeJSON(w, http.StatusOK, view) } // mapCurationErr maps an apply error to a typed envelope. It never returns a raw SQL or database error string. func mapCurationErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrNoMigratorPool): writeErr(w, http.StatusServiceUnavailable, "curation_unavailable", "the curation surface is not configured") case errors.Is(err, ErrProposalNotFound): writeErr(w, http.StatusNotFound, "not_found", "no such proposal") case errors.Is(err, ErrProposalNotPending): writeErr(w, http.StatusConflict, "not_pending", "the proposal is not pending and cannot be approved") case errors.Is(err, ErrProposalNotApproved): writeErr(w, http.StatusConflict, "not_approved", "the proposal must be approved before it can be verified") case errors.Is(err, ErrNotASubstitutionEdge): writeErr(w, http.StatusUnprocessableEntity, "not_an_edge", "only a substitutes for edge proposal can be approved or verified") case errors.Is(err, ErrNoStagedEdge): writeErr(w, http.StatusConflict, "no_staged_edge", "there is no staged edge to verify") case errors.Is(err, ErrVerifierMustDifferFromApprover): writeErr(w, http.StatusConflict, "separation_of_duties", "the verifier must differ from the approver") case errors.Is(err, ErrNoActor): writeErr(w, http.StatusForbidden, "no_actor", "no validated actor") case errors.Is(err, tenant.ErrNoTenant): writeErr(w, http.StatusForbidden, "no_tenant", "no validated tenant") default: writeErr(w, http.StatusInternalServerError, "curation_error", "the curation action could not be completed") } }