Some checks failed
ci / build (push) Has been cancelled
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
176 lines
5.6 KiB
Go
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})
|
|
}
|