fluid-core/internal/control/science.go
tegwick 3407b9cf85
Some checks failed
ci / build (push) Has been cancelled
Add science control APIs and the hypothesis, experiment and promote CLI
Completes FLUID-WP-0006. The loop now runs end to end from the command
line: two competing presentation hypotheses, an experiment that issues a
routing policy rather than touching traffic, an amendment, a stop that
returns traffic to the default, a confirmed outcome, a resolved
competition, and a promotion the gate can refuse.

Starting or stopping an experiment returns the routing policy for the
operator to install rather than installing it. Blueprint 17 keeps the
controller out of the traffic path, and installing from the handler
would put it straight back in; emitting the document keeps the
separation visible instead of implied.

`fluid audit trace` now answers the section 25 questions from events
rather than summary records, and names the rivals a hypothesis beat: an
audit asking which hypotheses were considered is not answered by naming
only the winner.

Two fixes found by driving the CLI rather than only the tests. Go's flag
package stops at the first positional, so ids given after flags silently
swallowed them; ids are now taken before parsing. And there was no way
to attach a revision to the hypothesis that produced it, which left
`audit trace` unable to say why a revision existed -- `hypothesis attach`
closes that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 06:44:47 +02:00

351 lines
11 KiB
Go

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")
}
}