Add control APIs, control-plane binary, and close the CLI gate bypass
Some checks failed
ci / build (push) Has been cancelled
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
This commit is contained in:
parent
a2d561eae5
commit
03ff7a8ad7
8 changed files with 930 additions and 35 deletions
128
internal/control/intent.go
Normal file
128
internal/control/intent.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package control
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/intent"
|
||||
"github.com/tegwick/fluid-core/internal/policy"
|
||||
)
|
||||
|
||||
// IntentAPI implements ArchitectureBlueprint.md section 44.5: read active
|
||||
// intent, read historical intent, validate a candidate against intent, propose
|
||||
// an amendment.
|
||||
//
|
||||
// Amendment is deliberately a proposal and not an edit. Blueprint section 22 is
|
||||
// blunt about it: the Daimon must not silently expand its own mission, and
|
||||
// intent changes require a separate governance process. This API records that
|
||||
// an amendment was proposed; it never enacts one.
|
||||
type IntentAPI struct {
|
||||
store *intent.Store
|
||||
gate *policy.Gate
|
||||
}
|
||||
|
||||
// NewIntentAPI returns the intent API.
|
||||
func NewIntentAPI(store *intent.Store, gate *policy.Gate) *IntentAPI {
|
||||
return &IntentAPI{store: store, gate: gate}
|
||||
}
|
||||
|
||||
// IntentResponse is a recorded intent version.
|
||||
type IntentResponse struct {
|
||||
Version string `json:"version"`
|
||||
Digest contract.Digest `json:"digest"`
|
||||
Mode string `json:"mode"`
|
||||
Document string `json:"document"`
|
||||
}
|
||||
|
||||
func (a *IntentAPI) handleCollection(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
a.record(w, r)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *IntentAPI) handleActive(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
v, err := a.store.Active(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, intent.ErrNoActive) {
|
||||
writeError(w, http.StatusNotFound, "no active interface evolution intent")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "could not read active intent")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResponse(v))
|
||||
}
|
||||
|
||||
func (a *IntentAPI) handleItem(w http.ResponseWriter, r *http.Request) {
|
||||
version := pathTail(r.URL.Path, "/control/v1/intents")
|
||||
if version == "" || version == "active" {
|
||||
a.handleActive(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
v, err := a.store.Get(r.Context(), version)
|
||||
if err != nil {
|
||||
writeError(w, statusForStoreError(err), "intent version not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResponse(v))
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// RecordIntentRequest records a new intent version.
|
||||
type RecordIntentRequest struct {
|
||||
Version string `json:"version"`
|
||||
Document string `json:"document"`
|
||||
Activate bool `json:"activate,omitempty"`
|
||||
}
|
||||
|
||||
func (a *IntentAPI) record(w http.ResponseWriter, r *http.Request) {
|
||||
var req RecordIntentRequest
|
||||
if err := decodeBody(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
v, err := a.store.Put(r.Context(), req.Version, req.Document)
|
||||
if err != nil {
|
||||
if errors.Is(err, intent.ErrImmutable) {
|
||||
// Rewriting a recorded version would change what already-published
|
||||
// revisions were governed by, so it is a conflict, not a bad request.
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Activate {
|
||||
if err := a.store.SetActive(r.Context(), v.Version); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "recorded but not activated", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, toResponse(v))
|
||||
}
|
||||
|
||||
func toResponse(v intent.Version) IntentResponse {
|
||||
return IntentResponse{
|
||||
Version: v.Version,
|
||||
Digest: v.Digest,
|
||||
Mode: v.Mode.String(),
|
||||
Document: v.Document,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue