package control import ( "errors" "net/http" "github.com/tegwick/fluid-core/internal/contract" "github.com/tegwick/fluid-core/internal/science" ) // ScienceAPI implements ArchitectureBlueprint.md sections 44.3 and 44.4: the // hypothesis and experiment control surfaces. // // It is one type rather than two because the operations are entangled — // starting an experiment moves its hypotheses, finalizing one moves them back — // and splitting them would mean two handlers reaching into the same lifecycle. type ScienceAPI struct { hypotheses *science.HypothesisStore experiments *science.ExperimentController } // NewScienceAPI returns the hypothesis and experiment APIs. func NewScienceAPI(h *science.HypothesisStore, e *science.ExperimentController) *ScienceAPI { return &ScienceAPI{hypotheses: h, experiments: e} } // ---------- hypotheses ---------- func (a *ScienceAPI) handleHypotheses(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: state := contract.FluidHypothesisState(r.URL.Query().Get("state")) if state != "" && !state.Valid() { writeError(w, http.StatusBadRequest, "unknown state filter") return } list, err := a.hypotheses.List(r.Context(), state) if err != nil { writeError(w, http.StatusInternalServerError, "could not list hypotheses") return } writeJSON(w, http.StatusOK, map[string]any{"hypotheses": list}) case http.MethodPost: var req struct { Hypothesis contract.FluidHypothesis `json:"hypothesis"` Actor contract.Actor `json:"actor"` } if err := decodeBody(r, &req); err != nil { writeError(w, http.StatusBadRequest, "could not decode request", err.Error()) return } if req.Actor.ID == "" { writeError(w, http.StatusBadRequest, "a hypothesis must name its author") return } h, err := a.hypotheses.Create(r.Context(), req.Hypothesis, req.Actor) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, h) default: writeError(w, http.StatusMethodNotAllowed, "method not allowed") } } // TransitionRequest moves a hypothesis through its lifecycle. type TransitionRequest struct { State contract.FluidHypothesisState `json:"state,omitempty"` Reason string `json:"reason"` Actor contract.Actor `json:"actor"` // Outcome closes a hypothesis under evaluation. Outcome *contract.FluidHypothesisOutcomeStatus `json:"outcome,omitempty"` Summary string `json:"summary,omitempty"` EvidenceRefs []contract.EvidenceRef `json:"evidence_refs,omitempty"` // AttachRevision and AttachExperiment link a hypothesis to its artifacts. AttachRevision contract.RevisionID `json:"attach_revision,omitempty"` AttachExperiment contract.ExperimentID `json:"attach_experiment,omitempty"` } func (a *ScienceAPI) handleHypothesisItem(w http.ResponseWriter, r *http.Request) { id := contract.HypothesisID(pathTail(r.URL.Path, "/control/v1/hypotheses")) if id == "" { writeError(w, http.StatusNotFound, "no hypothesis named") return } switch r.Method { case http.MethodGet: h, err := a.hypotheses.Get(r.Context(), id) if err != nil { writeError(w, statusForStoreError(err), "hypothesis not found") return } writeJSON(w, http.StatusOK, h) case http.MethodPatch: var req TransitionRequest if err := decodeBody(r, &req); err != nil { writeError(w, http.StatusBadRequest, "could not decode request", err.Error()) return } if req.Actor.ID == "" { writeError(w, http.StatusBadRequest, "a lifecycle change must name its actor") return } if req.AttachRevision != "" { if err := a.hypotheses.AttachRevision(r.Context(), id, req.AttachRevision, req.Actor); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } } if req.AttachExperiment != "" { if err := a.hypotheses.AttachExperiment(r.Context(), id, req.AttachExperiment, req.Actor); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } } if req.Outcome != nil { h, err := a.hypotheses.RecordOutcome(r.Context(), id, *req.Outcome, req.Summary, req.EvidenceRefs, req.Actor) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, h) return } if req.State != "" { h, err := a.hypotheses.Transition(r.Context(), id, req.State, req.Actor, req.Reason) if err != nil { // An incomplete hypothesis or a forbidden move is the caller // being told what the lifecycle requires, not a server fault. status := http.StatusUnprocessableEntity if errors.Is(err, science.ErrNotFound) { status = http.StatusNotFound } writeError(w, status, err.Error()) return } writeJSON(w, http.StatusOK, h) return } h, err := a.hypotheses.Get(r.Context(), id) if err != nil { writeError(w, statusForStoreError(err), "hypothesis not found") return } writeJSON(w, http.StatusOK, h) default: writeError(w, http.StatusMethodNotAllowed, "method not allowed") } } // CompeteRequest forms or resolves a competition group. type CompeteRequest struct { GroupID string `json:"group_id"` Members []contract.HypothesisID `json:"members,omitempty"` Winner contract.HypothesisID `json:"winner,omitempty"` Reason string `json:"reason,omitempty"` Actor contract.Actor `json:"actor"` } func (a *ScienceAPI) handleCompetition(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } var req CompeteRequest if err := decodeBody(r, &req); err != nil { writeError(w, http.StatusBadRequest, "could not decode request", err.Error()) return } if req.Actor.ID == "" { writeError(w, http.StatusBadRequest, "a competition change must name its actor") return } if req.Winner != "" { group, err := a.hypotheses.Resolve(r.Context(), req.GroupID, req.Winner, req.Actor, req.Reason) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, group) return } group, err := a.hypotheses.Compete(r.Context(), req.GroupID, req.Members, req.Actor) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, group) } // ---------- experiments ---------- func (a *ScienceAPI) handleExperiments(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: state := contract.FluidExperimentResultState(r.URL.Query().Get("state")) list, err := a.experiments.List(r.Context(), state) if err != nil { writeError(w, http.StatusInternalServerError, "could not list experiments") return } writeJSON(w, http.StatusOK, map[string]any{"experiments": list}) case http.MethodPost: var req struct { Experiment contract.FluidExperiment `json:"experiment"` Actor contract.Actor `json:"actor"` } if err := decodeBody(r, &req); err != nil { writeError(w, http.StatusBadRequest, "could not decode request", err.Error()) return } if req.Actor.ID == "" { writeError(w, http.StatusBadRequest, "an experiment must name its designer") return } e, err := a.experiments.Design(r.Context(), req.Experiment, req.Actor) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, e) default: writeError(w, http.StatusMethodNotAllowed, "method not allowed") } } // ExperimentActionRequest drives an experiment's lifecycle. // // The routing policy that starting or stopping produces is returned to the // caller rather than installed here. Blueprint section 17 keeps the controller // out of the traffic path, and installing policy from this handler would put it // straight back in. type ExperimentActionRequest struct { Action string `json:"action"` // start, stop, finalize, amend Generation int64 `json:"generation,omitempty"` DefaultRevision contract.RevisionID `json:"default_revision,omitempty"` Reason string `json:"reason,omitempty"` Preferred contract.RevisionID `json:"preferred_revision,omitempty"` Evidence []contract.EvidenceRef `json:"evidence_refs,omitempty"` Change string `json:"change,omitempty"` Actor contract.Actor `json:"actor"` } func (a *ScienceAPI) handleExperimentItem(w http.ResponseWriter, r *http.Request) { id := contract.ExperimentID(pathTail(r.URL.Path, "/control/v1/experiments")) if id == "" { writeError(w, http.StatusNotFound, "no experiment named") return } switch r.Method { case http.MethodGet: e, err := a.experiments.Get(r.Context(), id) if err != nil { writeError(w, statusForStoreError(err), "experiment not found") return } writeJSON(w, http.StatusOK, e) case http.MethodPost: var req ExperimentActionRequest if err := decodeBody(r, &req); err != nil { writeError(w, http.StatusBadRequest, "could not decode request", err.Error()) return } if req.Actor.ID == "" { writeError(w, http.StatusBadRequest, "an experiment action must name its actor") return } a.act(w, r, id, req) default: writeError(w, http.StatusMethodNotAllowed, "method not allowed") } } func (a *ScienceAPI) act(w http.ResponseWriter, r *http.Request, id contract.ExperimentID, req ExperimentActionRequest) { switch req.Action { case "start": e, policy, err := a.experiments.Start(r.Context(), id, req.Generation, req.DefaultRevision, req.Actor) if err != nil { status := http.StatusBadRequest if errors.Is(err, science.ErrTooManyExperiments) { // The concurrency limit is a temporary condition, not a // malformed request. status = http.StatusConflict } writeError(w, status, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ "experiment": e, "routing_policy": contract.RoutingPolicyDocument{RoutingPolicy: policy}, "note": "install this policy to expose the experiment; the controller does not route traffic itself", }) case "stop": e, policy, err := a.experiments.Stop(r.Context(), id, req.Generation, req.DefaultRevision, req.Actor, req.Reason) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ "experiment": e, "routing_policy": contract.RoutingPolicyDocument{RoutingPolicy: policy}, "note": "install this policy to return traffic to the default revision", }) case "finalize": e, err := a.experiments.Finalize(r.Context(), id, req.Preferred, req.Actor, req.Reason, req.Evidence) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, e) case "amend": e, err := a.experiments.Amend(r.Context(), id, req.Change, req.Reason, req.Actor) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, e) default: writeError(w, http.StatusBadRequest, "unknown action; expected start, stop, finalize or amend") } }