fluid-core/internal/control/revision.go
tegwick 03ff7a8ad7
Some checks failed
ci / build (push) Has been cancelled
Add control APIs, control-plane binary, and close the CLI gate bypass
Completes FLUID-WP-0004. The Revision and Intent APIs (Blueprint 44.1
and 44.5) are served by fluid-control, which is deliberately off the
request path and must never be reachable by interface consumers: it is
the mechanism that evolves the interface in response to their behaviour.

Intent amendment is a proposal, never an edit. Rewriting a recorded
version returns 409, because changing what a version says would change
what already-published revisions were governed by.

A rejected candidate comes back as 422 with its full stage report rather
than as a server fault. Rejection is a normal outcome (invariant 14) and
the reasons are the evidence a later hypothesis needs.

Also closes a real hole this workplan opened: `fluid revision publish`
previously wrote straight into the evidence store, which was a way
around the deterministic policy gate for anyone with shell access. It
now runs the same pipeline the control plane does and requires a signing
key.

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 03:00:17 +02:00

176 lines
5.6 KiB
Go

package control
import (
"encoding/json"
"errors"
"net/http"
"sort"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/publish"
)
// RevisionAPI implements ArchitectureBlueprint.md section 44.1: create, verify,
// publish, set state, query lineage.
//
// Create and verify are one operation here rather than two. A revision that
// exists but has not been verified has no use and no authority, and offering it
// as a separate resource would invite callers to treat it as one.
type RevisionAPI struct {
store evidence.Store
pipeline *publish.Pipeline
}
// NewRevisionAPI returns the revision API.
func NewRevisionAPI(store evidence.Store, p *publish.Pipeline) *RevisionAPI {
return &RevisionAPI{store: store, pipeline: p}
}
// CreateRevisionRequest submits a candidate for verification and publication.
type CreateRevisionRequest struct {
Descriptor contract.Revision `json:"descriptor"`
Origin contract.Actor `json:"origin"`
AdaptationClasses []contract.AdaptationClass `json:"adaptation_classes,omitempty"`
ComplexityDelta float64 `json:"complexity_delta,omitempty"`
RequestedTrafficShare float64 `json:"requested_traffic_share,omitempty"`
Approved bool `json:"approved,omitempty"`
ApprovedBy *contract.Actor `json:"approved_by,omitempty"`
}
// CreateRevisionResponse reports the outcome, verified or not.
type CreateRevisionResponse struct {
Revision contract.RevisionID `json:"revision"`
State string `json:"state"`
Report publish.Report `json:"report"`
// Descriptor is returned only on success, with its signature attached.
Descriptor *contract.Revision `json:"descriptor,omitempty"`
}
func (a *RevisionAPI) handleCollection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
a.list(w, r)
case http.MethodPost:
a.create(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (a *RevisionAPI) handleItem(w http.ResponseWriter, r *http.Request) {
id := pathTail(r.URL.Path, "/control/v1/revisions")
if id == "" {
writeError(w, http.StatusNotFound, "no revision named")
return
}
switch r.Method {
case http.MethodGet:
a.get(w, r, id)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// create runs a candidate through the pipeline and publishes it if it passes.
func (a *RevisionAPI) create(w http.ResponseWriter, r *http.Request) {
var req CreateRevisionRequest
if err := decodeBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
return
}
if req.Descriptor.ID == "" {
writeError(w, http.StatusBadRequest, "descriptor has no revision id")
return
}
if err := contract.RequireKind(string(req.Descriptor.ID), contract.KindRevision); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Origin.ID == "" {
writeError(w, http.StatusBadRequest, "every candidate must name its origin")
return
}
candidate := publish.NewCandidate(req.Descriptor, req.Origin)
verified, report, err := a.pipeline.Run(r.Context(), candidate, publish.PromotionRequest{
AdaptationClasses: req.AdaptationClasses,
ComplexityDelta: req.ComplexityDelta,
RequestedTrafficShare: req.RequestedTrafficShare,
Approved: req.Approved,
ApprovedBy: req.ApprovedBy,
})
if err != nil {
var rejected *publish.ErrRejected
if errors.As(err, &rejected) {
// A rejected candidate is a normal outcome (invariant 14), so it is
// reported as a result with its evidence rather than as a server
// fault. 422 says the request was well formed and the answer is no.
writeJSON(w, http.StatusUnprocessableEntity, CreateRevisionResponse{
Revision: candidate.ID(),
State: "REJECTED",
Report: report,
})
return
}
writeError(w, http.StatusInternalServerError, "pipeline failed", err.Error())
return
}
if err := a.pipeline.Publish(r.Context(), verified); err != nil {
writeError(w, http.StatusInternalServerError, "verified but not published", err.Error())
return
}
d := verified.Descriptor()
writeJSON(w, http.StatusCreated, CreateRevisionResponse{
Revision: d.ID,
State: string(d.State),
Report: report,
Descriptor: &d,
})
}
func (a *RevisionAPI) get(w http.ResponseWriter, r *http.Request, id string) {
body, err := a.store.Record(r.Context(), contract.KindRevision, id)
if err != nil {
writeError(w, statusForStoreError(err), "revision not found")
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}
// LineageEntry is one step in a revision's ancestry.
type LineageEntry struct {
Revision contract.RevisionID `json:"revision"`
State string `json:"state"`
Intent string `json:"intent"`
}
func (a *RevisionAPI) list(w http.ResponseWriter, r *http.Request) {
records, err := a.store.Records(r.Context(), contract.KindRevision)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not list revisions")
return
}
out := make([]LineageEntry, 0, len(records))
for _, body := range records {
var d contract.Revision
if err := json.Unmarshal(body, &d); err != nil {
continue
}
out = append(out, LineageEntry{
Revision: d.ID,
State: string(d.State),
Intent: d.Intent.Version,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Revision < out[j].Revision })
writeJSON(w, http.StatusOK, map[string]any{"revisions": out})
}