2026-09-04 03:00:17 +02:00
|
|
|
// Package control implements the FLUID control-plane APIs.
|
|
|
|
|
//
|
|
|
|
|
// ArchitectureBlueprint.md section 44 asks for a small set of internal control
|
|
|
|
|
// APIs. They are internal on purpose: this is the surface that publishes
|
|
|
|
|
// revisions and records governance decisions, and it must never be reachable by
|
|
|
|
|
// the consumers whose behaviour it evolves in response to.
|
|
|
|
|
//
|
|
|
|
|
// Nothing here is on the request path. The data plane keeps serving when this
|
|
|
|
|
// server is down (invariant 2), which is also why the CLI reads the evidence
|
|
|
|
|
// store directly rather than through these endpoints.
|
|
|
|
|
package control
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Server exposes the control APIs over HTTP.
|
|
|
|
|
type Server struct {
|
|
|
|
|
revisions *RevisionAPI
|
|
|
|
|
intents *IntentAPI
|
2026-09-04 03:19:06 +02:00
|
|
|
pressure *PressureAPI
|
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
|
|
|
science *ScienceAPI
|
2026-09-04 03:00:17 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:19:06 +02:00
|
|
|
// NewServer wires the control APIs. The pressure API may be nil where an
|
|
|
|
|
// interface runs without an observation plane.
|
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
|
|
|
func NewServer(rev *RevisionAPI, in *IntentAPI, p *PressureAPI, sci *ScienceAPI) *Server {
|
|
|
|
|
return &Server{revisions: rev, intents: in, pressure: p, science: sci}
|
2026-09-04 03:00:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Routes returns the control-plane mux.
|
|
|
|
|
func (s *Server) Routes() *http.ServeMux {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
|
|
|
|
mux.HandleFunc("/control/v1/revisions", s.revisions.handleCollection)
|
|
|
|
|
mux.HandleFunc("/control/v1/revisions/", s.revisions.handleItem)
|
|
|
|
|
mux.HandleFunc("/control/v1/intents", s.intents.handleCollection)
|
|
|
|
|
mux.HandleFunc("/control/v1/intents/", s.intents.handleItem)
|
|
|
|
|
mux.HandleFunc("/control/v1/intents/active", s.intents.handleActive)
|
|
|
|
|
|
2026-09-04 03:19:06 +02:00
|
|
|
if s.pressure != nil {
|
|
|
|
|
mux.HandleFunc("/control/v1/pressure", s.pressure.handleCollection)
|
|
|
|
|
mux.HandleFunc("/control/v1/pressure/", s.pressure.handleItem)
|
|
|
|
|
mux.HandleFunc("/control/v1/telemetry", s.pressure.handleTelemetry)
|
|
|
|
|
// Consumer-reachable, unlike the rest of this surface.
|
|
|
|
|
mux.HandleFunc("/v1/feedback", s.pressure.handleFeedback)
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
if s.science != nil {
|
|
|
|
|
mux.HandleFunc("/control/v1/hypotheses", s.science.handleHypotheses)
|
|
|
|
|
mux.HandleFunc("/control/v1/hypotheses/", s.science.handleHypothesisItem)
|
|
|
|
|
mux.HandleFunc("/control/v1/competitions", s.science.handleCompetition)
|
|
|
|
|
mux.HandleFunc("/control/v1/experiments", s.science.handleExperiments)
|
|
|
|
|
mux.HandleFunc("/control/v1/experiments/", s.science.handleExperimentItem)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:00:17 +02:00
|
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// apiError is the control-plane error shape.
|
|
|
|
|
type apiError struct {
|
|
|
|
|
Error string `json:"error"`
|
|
|
|
|
Detail string `json:"detail,omitempty"`
|
|
|
|
|
Causes []string `json:"causes,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(status)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string, causes ...string) {
|
|
|
|
|
writeJSON(w, status, apiError{Error: msg, Causes: causes})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// statusForStoreError maps store failures onto HTTP without leaking detail.
|
|
|
|
|
func statusForStoreError(err error) int {
|
|
|
|
|
if errors.Is(err, evidence.ErrNotFound) {
|
|
|
|
|
return http.StatusNotFound
|
|
|
|
|
}
|
|
|
|
|
return http.StatusInternalServerError
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pathTail returns the segment after prefix, or "" when there is none.
|
|
|
|
|
func pathTail(path, prefix string) string {
|
|
|
|
|
rest := strings.TrimPrefix(path, prefix)
|
|
|
|
|
rest = strings.Trim(rest, "/")
|
|
|
|
|
if rest == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if i := strings.Index(rest, "/"); i >= 0 {
|
|
|
|
|
return rest[:i]
|
|
|
|
|
}
|
|
|
|
|
return rest
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func decodeBody(r *http.Request, into any) error {
|
|
|
|
|
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
|
|
|
|
dec.DisallowUnknownFields()
|
|
|
|
|
if err := dec.Decode(into); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|