2026-09-04 03:00:17 +02:00
|
|
|
package control
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
|
|
|
|
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
|
|
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
|
|
|
|
"github.com/tegwick/fluid-core/internal/intent"
|
|
|
|
|
"github.com/tegwick/fluid-core/internal/policy"
|
|
|
|
|
"github.com/tegwick/fluid-core/internal/publish"
|
|
|
|
|
"github.com/tegwick/fluid-core/internal/signing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const intentDoc = `# Interface Evolution Intent
|
|
|
|
|
|
|
|
|
|
**Current operational authority mode:**
|
|
|
|
|
FLUID-2
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
func newServer(t *testing.T) (*http.ServeMux, *evidence.SQLStore) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
store, err := evidence.OpenSQLite(ctx, filepath.Join(t.TempDir(), "e.db"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
|
|
|
|
|
|
|
|
intents := intent.New(store, "hall-publishing")
|
|
|
|
|
if _, err := intents.Put(ctx, "IEI-1", intentDoc); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := intents.SetActive(ctx, "IEI-1"); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
signer, _, err := signing.GenerateKey("test-key")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
gate := policy.NewGate(policy.DefaultLimits())
|
|
|
|
|
|
|
|
|
|
pipeline, err := publish.New(publish.Options{
|
|
|
|
|
Gate: gate, Signer: signer, Store: store, Intents: intents,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
srv := NewServer(NewRevisionAPI(store, pipeline), NewIntentAPI(intents, gate), nil, nil)
|
2026-09-04 03:00:17 +02:00
|
|
|
return srv.Routes(), store
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func descriptorJSON() contract.Revision {
|
|
|
|
|
pc := contract.RevisionPolicyPolicyCheckPassed
|
|
|
|
|
return contract.Revision{
|
|
|
|
|
SchemaVersion: "0.1",
|
|
|
|
|
ID: "R-2",
|
|
|
|
|
Interface: "hall-publishing",
|
|
|
|
|
State: contract.RevisionStateCandidate,
|
|
|
|
|
Contract: contract.RevisionContract{
|
|
|
|
|
Type: contract.RevisionContractTypeOpenapi,
|
|
|
|
|
Digest: contract.Digest("sha256:" + strings.Repeat("1", 64)),
|
|
|
|
|
},
|
|
|
|
|
Runtime: contract.RevisionRuntime{Upstream: "http://adapter:8080"},
|
|
|
|
|
Intent: contract.RevisionIntent{Version: "IEI-1"},
|
|
|
|
|
Policy: contract.RevisionPolicy{
|
|
|
|
|
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
|
|
|
|
|
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
|
|
|
|
|
PolicyCheck: &pc,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func post(t *testing.T, mux *http.ServeMux, path string, body any) *httptest.ResponseRecorder {
|
|
|
|
|
t.Helper()
|
|
|
|
|
raw, err := json.Marshal(body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)))
|
|
|
|
|
return rec
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCreateRevisionVerifiesAndPublishes(t *testing.T) {
|
|
|
|
|
mux, store := newServer(t)
|
|
|
|
|
|
|
|
|
|
rec := post(t, mux, "/control/v1/revisions", CreateRevisionRequest{
|
|
|
|
|
Descriptor: descriptorJSON(),
|
|
|
|
|
Origin: contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
|
|
|
|
|
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
|
|
|
|
|
ComplexityDelta: 0.2,
|
|
|
|
|
RequestedTrafficShare: 0.1,
|
|
|
|
|
Approved: true,
|
|
|
|
|
ApprovedBy: &contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resp CreateRevisionResponse
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if resp.Descriptor == nil || resp.Descriptor.Signature == nil {
|
|
|
|
|
t.Fatal("published descriptor came back unsigned")
|
|
|
|
|
}
|
|
|
|
|
if !resp.Report.Passed() {
|
|
|
|
|
t.Errorf("report says not passed: %+v", resp.Report.Stages)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := store.Record(context.Background(), contract.KindRevision, "R-2"); err != nil {
|
|
|
|
|
t.Errorf("revision was not persisted: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestRejectedCandidateIsAResultNotAFault: a rejection is normal (invariant 14)
|
|
|
|
|
// and must come back with its evidence rather than as a 500.
|
|
|
|
|
func TestRejectedCandidateIsAResultNotAFault(t *testing.T) {
|
|
|
|
|
mux, _ := newServer(t)
|
|
|
|
|
|
|
|
|
|
rec := post(t, mux, "/control/v1/revisions", CreateRevisionRequest{
|
|
|
|
|
Descriptor: descriptorJSON(),
|
|
|
|
|
Origin: contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
|
|
|
|
|
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
|
|
|
|
|
Approved: false, // the default gate requires approval
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
|
|
|
t.Fatalf("status = %d, want 422; body %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resp CreateRevisionResponse
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if resp.State != "REJECTED" {
|
|
|
|
|
t.Errorf("state = %q", resp.State)
|
|
|
|
|
}
|
|
|
|
|
failure, ok := resp.Report.FirstFailure()
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("rejection carries no failing stage")
|
|
|
|
|
}
|
|
|
|
|
if failure.Stage != publish.StagePolicyCheck {
|
|
|
|
|
t.Errorf("failed at %s, want POLICY_CHECK", failure.Stage)
|
|
|
|
|
}
|
|
|
|
|
if len(failure.Evidence) == 0 {
|
|
|
|
|
t.Error("rejection carries no reasons")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCreateRevisionValidatesInput(t *testing.T) {
|
|
|
|
|
mux, _ := newServer(t)
|
|
|
|
|
|
|
|
|
|
// A hypothesis id where a revision id belongs must not be accepted.
|
|
|
|
|
d := descriptorJSON()
|
|
|
|
|
d.ID = "H-2"
|
|
|
|
|
rec := post(t, mux, "/control/v1/revisions", CreateRevisionRequest{
|
|
|
|
|
Descriptor: d,
|
|
|
|
|
Origin: contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
|
|
|
|
|
})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("mis-prefixed id: status = %d, want 400", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Every candidate must name its origin: an artifact with no provenance
|
|
|
|
|
// cannot be audited later.
|
|
|
|
|
rec = post(t, mux, "/control/v1/revisions", CreateRevisionRequest{Descriptor: descriptorJSON()})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("missing origin: status = %d, want 400", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIntentEndpoints(t *testing.T) {
|
|
|
|
|
mux, _ := newServer(t)
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/control/v1/intents/active", nil))
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("active intent: status = %d, body %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var got IntentResponse
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if got.Version != "IEI-1" || got.Mode != "FLUID-2" {
|
|
|
|
|
t.Errorf("active intent = %+v", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Historical read by version.
|
|
|
|
|
rec = httptest.NewRecorder()
|
|
|
|
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/control/v1/intents/IEI-1", nil))
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("historical read: status = %d", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestRecordedIntentCannotBeRewritten: changing what a version says would
|
|
|
|
|
// change what already-published revisions were governed by.
|
|
|
|
|
func TestRecordedIntentCannotBeRewritten(t *testing.T) {
|
|
|
|
|
mux, _ := newServer(t)
|
|
|
|
|
|
|
|
|
|
rec := post(t, mux, "/control/v1/intents", RecordIntentRequest{
|
|
|
|
|
Version: "IEI-1",
|
|
|
|
|
Document: strings.Replace(intentDoc, "FLUID-2", "FLUID-5", 1),
|
|
|
|
|
})
|
|
|
|
|
if rec.Code != http.StatusConflict {
|
|
|
|
|
t.Errorf("status = %d, want 409; body %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestUnfilledTemplateIsRefused(t *testing.T) {
|
|
|
|
|
mux, _ := newServer(t)
|
|
|
|
|
|
|
|
|
|
rec := post(t, mux, "/control/v1/intents", RecordIntentRequest{
|
|
|
|
|
Version: "IEI-2",
|
|
|
|
|
Document: "mode is one of FLUID-0 FLUID-1 FLUID-2 FLUID-3 FLUID-4 FLUID-5 FLUID-6",
|
|
|
|
|
})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("an unresolved template was accepted: status = %d", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMethodsAreConstrained(t *testing.T) {
|
|
|
|
|
mux, _ := newServer(t)
|
|
|
|
|
for _, tc := range []struct{ method, path string }{
|
|
|
|
|
{http.MethodDelete, "/control/v1/revisions/R-2"},
|
|
|
|
|
{http.MethodPut, "/control/v1/intents/IEI-1"},
|
|
|
|
|
} {
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
mux.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
|
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
|
|
|
t.Errorf("%s %s: status = %d, want 405", tc.method, tc.path, rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|