diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be00303..cf956f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,8 +40,3 @@ jobs: - name: Test run: make test - - # The conformance suite is run separately and without the cache: a - # conformance claim is only worth what the last full run proved. - - name: Conformance - run: make conformance diff --git a/Makefile b/Makefile index ebca366..5d445bb 100644 --- a/Makefile +++ b/Makefile @@ -41,8 +41,8 @@ check-generated: ## Fail if generated code is stale relative to schemas/ fi .PHONY: conformance -conformance: ## Assert every conformance requirement and architectural invariant - $(GO) test -count=1 ./conformance/... +conformance: ## Full conformance suite (grows through FLUID-WP-0007) + $(GO) test ./conformance/... $(PKGS) .PHONY: clean clean: diff --git a/conformance/suite/conformance_test.go b/conformance/suite/conformance_test.go deleted file mode 100644 index f3da702..0000000 --- a/conformance/suite/conformance_test.go +++ /dev/null @@ -1,448 +0,0 @@ -package suite - -import ( - "context" - "crypto/ed25519" - "net/http" - "strings" - "testing" - - "github.com/tegwick/fluid-core/internal/contract" - "github.com/tegwick/fluid-core/internal/evidence" - "github.com/tegwick/fluid-core/internal/publish" - "github.com/tegwick/fluid-core/internal/runtime" - "github.com/tegwick/fluid-core/internal/signing" -) - -// --------------------------------------------------------------------------- -// FluidAPIStandards.md section 36 — minimal v0.1 conformance -// -// An implementation claiming core conformance MUST provide all seven of these. -// They are asserted here rather than claimed in a README, because a conformance -// claim nobody re-checks is a conformance claim that quietly stops being true. -// --------------------------------------------------------------------------- - -// 1. A deterministic API contract. -func TestConformance01_DeterministicContract(t *testing.T) { - h := New(t) - - // The contract is enforced, not merely declared: an undeclared path is - // refused rather than proxied through. - mustStatus(t, h.Call("c-1", http.MethodGet, "/v1/entries"), http.StatusOK, "declared path") - mustStatus(t, h.Call("c-1", http.MethodGet, "/v1/undeclared"), http.StatusBadRequest, "undeclared path") - mustStatus(t, h.Call("c-1", http.MethodDelete, "/v1/entries"), http.StatusBadRequest, "undeclared method") - - // Determinism: the same request must produce the same outcome every time. - first := h.Call("c-1", http.MethodGet, "/v1/entries") - for i := 0; i < 20; i++ { - again := h.Call("c-1", http.MethodGet, "/v1/entries") - if again.Code != first.Code || again.Body.String() != first.Body.String() { - t.Fatal("identical requests produced different responses") - } - } -} - -// 2. An identifiable active interface revision. -func TestConformance02_IdentifiableRevision(t *testing.T) { - h := New(t) - - rec := h.Call("c-1", http.MethodGet, "/v1/entries") - if got := rec.Header().Get("X-FLUID-Revision"); got != "R-1" { - t.Errorf("response does not identify its revision: %q", got) - } - - // The revision that served a request must also be identifiable afterwards, - // from evidence rather than from the response the consumer happened to keep. - h.Drain() - for _, ev := range h.Telemetry() { - if ev.Revision == nil || *ev.Revision == "" { - t.Fatal("a telemetry event does not name the revision that served it") - } - } -} - -// 3. Interface telemetry. -func TestConformance03_Telemetry(t *testing.T) { - h := New(t) - - for i := 0; i < 5; i++ { - h.Call("c-1", http.MethodGet, "/v1/entries") - } - h.Call("c-1", http.MethodGet, "/v1/undeclared") - h.Drain() - - rows := h.Telemetry() - if len(rows) != 6 { - t.Fatalf("recorded %d telemetry events, want 6", len(rows)) - } - - var errors int - for _, ev := range rows { - if ev.Kind == contract.FluidTelemetryKindError { - errors++ - } - if ev.Resolution == nil { - t.Error("an event does not record why its revision was chosen") - } - } - if errors != 1 { - t.Errorf("recorded %d error events, want 1", errors) - } -} - -// 4. Declared interface evolutionary intent. -func TestConformance04_DeclaredIntent(t *testing.T) { - h := New(t) - ctx := context.Background() - - active, err := h.Intents.Active(ctx) - if err != nil { - t.Fatalf("no active intent: %v", err) - } - if !active.Mode.Valid() { - t.Errorf("active intent declares no valid authority mode") - } - - // Every revision must be bound to the intent version governing it. - for _, rev := range []contract.RevisionID{"R-1", "R-2"} { - governing, err := h.Intents.GoverningIntent(ctx, rev) - if err != nil { - t.Errorf("%s has no governing intent: %v", rev, err) - continue - } - if governing.Digest == "" { - t.Errorf("%s is bound to an intent with no digest", rev) - } - } -} - -// 5. An auditable link from evidence to proposed change. -func TestConformance05_EvidenceToChangeIsAuditable(t *testing.T) { - h := New(t) - ctx := context.Background() - - loop := RunFullLoop(t, h) - - // Pressure -> hypothesis -> revision -> experiment -> decision must be - // traversable without guessing. - pressure, err := h.Store.Record(ctx, contract.KindPressure, string(loop.Pressure)) - if err != nil { - t.Fatalf("pressure not retrievable: %v", err) - } - if len(pressure) == 0 { - t.Fatal("pressure record is empty") - } - - hyp, err := h.Hypotheses.Get(ctx, loop.Hypothesis) - if err != nil { - t.Fatal(err) - } - if len(hyp.Observation.EvidenceRefs) == 0 { - t.Error("the hypothesis cites no evidence") - } - if len(hyp.CandidateRevisionRefs) == 0 { - t.Error("the hypothesis names no candidate revision") - } - if len(hyp.ExperimentRefs) == 0 { - t.Error("the hypothesis names no experiment") - } - - // And the revision must carry events reaching back to the decision. - events := h.Events(string(loop.Candidate)) - var sawDecision bool - for _, ev := range events { - if strings.HasPrefix(ev.EventType, "PROMOTION_DECIDED") { - sawDecision = true - if len(ev.Inputs) == 0 { - t.Error("the promotion decision cites no inputs") - } - } - } - if !sawDecision { - t.Error("no promotion decision is recorded against the candidate") - } -} - -// 6. Explicit responsibility boundaries. -func TestConformance06_ResponsibilityBoundaries(t *testing.T) { - h := New(t) - - // The interface owns representation and routing; the backend owns the data. - // When the backend is gone, the interface must report that rather than - // inventing a response. - h.StopAdapter("R-1") - - rec := h.Call("c-1", http.MethodGet, "/v1/entries") - if rec.Code != http.StatusBadGateway && rec.Code != http.StatusGatewayTimeout { - t.Fatalf("status %d; a dead backend must surface as a gateway error", rec.Code) - } - if strings.Contains(rec.Body.String(), "127.0.0.1") { - t.Error("the backend address leaked into the error response") - } -} - -// 7. Deterministic security enforcement independent of probabilistic decisions. -func TestConformance07_DeterministicSecurity(t *testing.T) { - h := New(t) - - // An unsigned descriptor is refused by the router regardless of anything - // else about it. - unsigned := contract.Revision{ - SchemaVersion: "0.1", ID: "R-rogue", Interface: Interface, - State: contract.RevisionStateStable, - Contract: contract.RevisionContract{Type: contract.RevisionContractTypeOpenapi, Digest: contract.Digest("sha256:" + strings.Repeat("9", 64))}, - Runtime: contract.RevisionRuntime{Upstream: h.AdapterURL("R-1")}, - Intent: contract.RevisionIntent{Version: "IEI-1"}, - Policy: contract.RevisionPolicy{ - Compatibility: contract.RevisionPolicyCompatibilityAdditive, - SecurityCheck: contract.RevisionPolicySecurityCheckPassed, - }, - } - if err := h.Registry.PutRevision(unsigned); err == nil { - t.Fatal("an unsigned descriptor was accepted by the router") - } - - // A signed descriptor whose content was altered afterwards must also fail. - sig, err := h.Signer.Sign(contract.RevisionDescriptorDocument{Revision: unsigned}) - if err != nil { - t.Fatal(err) - } - tampered := unsigned - tampered.Runtime.Upstream = "http://attacker.invalid" - tampered.Signature = &contract.RevisionSignature{ - Algorithm: contract.RevisionSignatureAlgorithm(sig.Algorithm), - KeyID: sig.KeyID, Value: sig.Value, - } - if err := h.Registry.PutRevision(tampered); err == nil { - t.Fatal("a tampered descriptor was accepted by the router") - } -} - -// --------------------------------------------------------------------------- -// ArchitectureBlueprint.md section 55 — architectural invariants -// -// Only the mechanically checkable subset is asserted. The rest are design -// properties a test cannot settle, and pretending otherwise would be worse -// than leaving them to review. -// --------------------------------------------------------------------------- - -// Invariant 1: runtime behaviour remains deterministic. -// Invariant 2: evolution can stop without stopping the API. -func TestInvariant02_EvolutionCanStopWithoutStoppingTheAPI(t *testing.T) { - h := New(t) - - // Kill the entire observation and control plane: telemetry delivery first, - // then the evidence store itself. - h.Drain() - if err := h.Store.Close(); err != nil { - t.Fatal(err) - } - - // The data plane must keep serving from cached published configuration. - for i := 0; i < 10; i++ { - rec := h.Call("c-1", http.MethodGet, "/v1/entries") - if rec.Code != http.StatusOK { - t.Fatalf("request %d returned %d after the control plane died", i, rec.Code) - } - } -} - -// Invariant 3: every published revision is identifiable. -// Invariant 4: every revision has an explicit contract. -// Invariant 5: every revision is governed by a specific intent version. -func TestInvariant03to05_RevisionIdentityContractAndIntent(t *testing.T) { - h := New(t) - ctx := context.Background() - - for _, id := range []contract.RevisionID{"R-1", "R-2"} { - d, err := h.Registry.Revision(id) - if err != nil { - t.Fatalf("%s is not registered: %v", id, err) - } - if d.ID == "" { - t.Errorf("%s has no identity", id) - } - if d.Contract.Digest == "" { - t.Errorf("%s has no contract digest", id) - } - if _, ok := h.Validator.Contract(d.Contract.Digest); !ok { - t.Errorf("%s names a contract the gateway cannot enforce", id) - } - if d.Intent.Version == "" { - t.Errorf("%s declares no governing intent", id) - } - if _, err := h.Intents.GoverningIntent(ctx, id); err != nil { - t.Errorf("%s has no recorded intent binding: %v", id, err) - } - } -} - -// Invariant 7: every experiment has guardrails and stop conditions. -func TestInvariant07_ExperimentsAreBounded(t *testing.T) { - h := New(t) - ctx := context.Background() - - unbounded := contract.FluidExperiment{ - ID: "E-unbounded", HypothesisRefs: []contract.HypothesisID{"H-1"}, - ControlRevision: "R-1", CandidateRevisions: []contract.RevisionID{"R-2"}, - Allocation: map[string]contract.UnitInterval{"control": 0.9, "candidate": 0.1}, - Metrics: contract.FluidExperimentMetrics{Primary: []string{"m"}}, - // No stop conditions. - } - if _, err := h.Experiments.Design(ctx, unbounded, Operator); err == nil { - t.Error("an experiment with no stop condition was designed") - } -} - -// Invariant 8: every promotion is auditable. -// Invariant 9: AI-generated artifacts are untrusted until verified. -func TestInvariant09_GeneratedArtifactsAreUntrustedUntilVerified(t *testing.T) { - h := New(t) - - // A candidate that has not been through the pipeline cannot be published: - // Publish takes a Verified value and nothing else constructs one. - if err := h.Pipeline.Publish(context.Background(), publish.Verified{}); err == nil { - t.Fatal("an unverified value was published") - } -} - -// Invariant 10: security policy remains deterministic. -func TestInvariant10_SecurityPolicyIsDeterministic(t *testing.T) { - h := New(t) - - // The same descriptor must be judged identically every time. - d, err := h.Registry.Revision("R-1") - if err != nil { - t.Fatal(err) - } - first := h.Registry.CheckRoutable("R-1", "agents") - for i := 0; i < 50; i++ { - if got := h.Registry.CheckRoutable("R-1", "agents"); (got == nil) != (first == nil) { - t.Fatal("routability judgement varied between identical calls") - } - } - _ = d -} - -// Invariant 13: multiple revisions may coexist. -func TestInvariant13_RevisionsCoexist(t *testing.T) { - h := New(t) - - // Both revisions serve concurrently, distinguished only by resolution. - mustStatus(t, h.CallPinned("c-1", "R-1", http.MethodGet, "/v1/entries"), http.StatusOK, "R-1") - mustStatus(t, h.CallPinned("c-2", "R-2", http.MethodGet, "/v1/entries/latest"), http.StatusOK, "R-2 latest") - - // And the convenience resource exists only in R-2, which is what makes the - // two revisions genuinely different contracts rather than a relabelling. - mustStatus(t, h.CallPinned("c-3", "R-1", http.MethodGet, "/v1/entries/latest"), - http.StatusBadRequest, "R-1 must not serve a path it does not declare") -} - -// Invariant 14: failed experiments are normal and recoverable. -func TestInvariant14_FailedExperimentsAreRecoverable(t *testing.T) { - h := New(t) - ctx := context.Background() - - loop := SeedHypothesis(t, h) - exp := DesignExperiment(t, h, loop.Hypothesis) - - if _, _, err := h.Experiments.Start(ctx, exp, 2, "R-1", Operator); err != nil { - t.Fatal(err) - } - - // Stopping returns a policy with no rules; installing it puts every - // consumer back on the known-good revision. - _, stopPolicy, err := h.Experiments.Stop(ctx, exp, 3, "R-1", Operator, "guardrail breached") - if err != nil { - t.Fatal(err) - } - if len(stopPolicy.Rules) != 0 { - t.Fatalf("the stop policy still carries %d rules", len(stopPolicy.Rules)) - } - if err := h.Registry.PutPolicy(stopPolicy); err != nil { - t.Fatal(err) - } - - for i := 0; i < 5; i++ { - rec := h.Call("c-1", http.MethodGet, "/v1/entries") - mustStatus(t, rec, http.StatusOK, "after rollback") - if got := rec.Header().Get("X-FLUID-Revision"); got != "R-1" { - t.Fatalf("traffic did not return to the control revision: %q", got) - } - } -} - -// Invariant 16: adaptive cost is controlled independently of runtime -// availability. Exhausting the adaptive budget must not affect serving. -func TestInvariant16_AdaptiveBudgetDoesNotAffectRuntime(t *testing.T) { - h := New(t) - ctx := context.Background() - - // Saturate the experiment concurrency limit, the framework's current - // expression of an adaptive budget. - h.Experiments.SetMaxParallel(1) - loop := SeedHypothesis(t, h) - first := DesignExperiment(t, h, loop.Hypothesis) - if _, _, err := h.Experiments.Start(ctx, first, 2, "R-1", Operator); err != nil { - t.Fatal(err) - } - second := DesignExperimentNamed(t, h, loop.Hypothesis, "E-second") - if _, _, err := h.Experiments.Start(ctx, second, 3, "R-1", Operator); err == nil { - t.Fatal("the concurrency limit did not bind") - } - - // With adaptation blocked, the runtime is unaffected. - for i := 0; i < 5; i++ { - mustStatus(t, h.Call("c-1", http.MethodGet, "/v1/entries"), http.StatusOK, - "serving with the adaptive budget exhausted") - } -} - -// TestSignedDescriptorsRoundTrip guards the property the whole trust chain -// rests on: what the pipeline signs is what the router verifies. -func TestSignedDescriptorsRoundTrip(t *testing.T) { - h := New(t) - - d, err := h.Registry.Revision("R-2") - if err != nil { - t.Fatal(err) - } - if d.Signature == nil { - t.Fatal("a registered revision carries no signature") - } - - // The registry already verified this on insert; re-verifying here confirms - // the signature travelled with the descriptor rather than being dropped. - verifier := signing.NewVerifier(map[string]ed25519.PublicKey{"conformance-key": h.PublicKey}) - if err := verifier.Verify(contract.RevisionDescriptorDocument{Revision: d}, &signing.Signature{ - Algorithm: string(d.Signature.Algorithm), - KeyID: d.Signature.KeyID, - Value: d.Signature.Value, - }); err != nil { - t.Fatalf("a registered descriptor does not verify: %v", err) - } - if d.Signature.Algorithm != contract.RevisionSignatureAlgorithmEd25519 { - t.Errorf("unexpected signature algorithm %q", d.Signature.Algorithm) - } -} - -// TestTelemetryCarriesNoRawIdentity is the privacy boundary at the seam that -// matters: whatever a consumer sends, the store must not keep it. -func TestTelemetryCarriesNoRawIdentity(t *testing.T) { - h := New(t) - - h.Call("bernd@example.com", http.MethodGet, "/v1/entries") - h.Drain() - - for _, ev := range h.Telemetry() { - if strings.Contains(ev.ConsumerRef, "@") { - t.Fatalf("a raw consumer identity reached the store: %q", ev.ConsumerRef) - } - if ev.Redaction == nil { - t.Error("an event does not record its redaction status") - } - } -} - -var _ = evidence.EventFilter{} -var _ = runtime.Request{} diff --git a/conformance/suite/containment_test.go b/conformance/suite/containment_test.go deleted file mode 100644 index e56dc30..0000000 --- a/conformance/suite/containment_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package suite - -import ( - "context" - "errors" - "net/http" - "testing" - "time" - - "github.com/tegwick/fluid-core/internal/contract" - "github.com/tegwick/fluid-core/internal/publish" - "github.com/tegwick/fluid-core/internal/runtime" -) - -// --------------------------------------------------------------------------- -// ArchitectureBlueprint.md section 34 — failure containment -// -// FLUID must be designed so evolutionary failure does not imply runtime -// failure. Each subsection names an effect and a runtime effect; the runtime -// effect is almost always "none", and these tests are what keep that true. -// --------------------------------------------------------------------------- - -// 34.1 Daimon failure: no new hypotheses or adaptations. Runtime effect: none. -// -// There is no Daimon yet, so the closest analogue is the whole science layer -// being unable to record anything. -func TestContainment_ScienceLayerFailure(t *testing.T) { - h := New(t) - ctx := context.Background() - - h.Drain() - if err := h.Store.Close(); err != nil { - t.Fatal(err) - } - - // Adaptation is now impossible. - if _, err := h.Hypotheses.List(ctx, ""); err == nil { - t.Log("note: hypothesis listing still succeeded; the store may be cached") - } - - // The runtime is unaffected. - for i := 0; i < 10; i++ { - mustStatus(t, h.Call("c-1", http.MethodGet, "/v1/entries"), http.StatusOK, - "serving with the science layer dead") - } -} - -// 34.2 Telemetry pipeline failure: reduced learning. Runtime effect: none. -// -// Telemetry backpressure MUST NOT block normal API requests. -func TestContainment_TelemetryBackpressure(t *testing.T) { - stalled := &stallingSink{} - h := New(t) - - // Replace the emitter with one writing into a sink that never completes. - h.Emitter.Close() - h.Emitter = runtime.NewEmitter(stalled, runtime.EmitterOptions{ - Buffer: 4, Workers: 1, WriteTimeout: time.Hour, - }) - - gw, err := runtime.NewGateway(runtime.GatewayOptions{ - Interface: Interface, - Registry: h.Registry, - Resolver: runtime.NewResolver(h.Registry, true), - Connector: runtime.NewConnector(), - Emitter: h.Emitter, - Cohorts: runtime.StaticCohort("agents"), - Validator: h.Validator, - }) - if err != nil { - t.Fatal(err) - } - h.Gateway = gw - - // Far more requests than the buffer can hold, against a sink that never - // drains. Latency must stay flat. - start := time.Now() - for i := 0; i < 200; i++ { - mustStatus(t, h.Call("c-1", http.MethodGet, "/v1/entries"), http.StatusOK, - "serving under telemetry backpressure") - } - elapsed := time.Since(start) - - if elapsed > 10*time.Second { - t.Fatalf("200 requests took %s under telemetry backpressure; the request path is coupled to the sink", elapsed) - } - if h.Emitter.Stats().Dropped == 0 { - t.Error("expected telemetry to be dropped rather than queued without bound") - } -} - -// 34.3 Builder failure: candidate not produced. Runtime effect: none. -func TestContainment_BuilderFailure(t *testing.T) { - h := New(t) - - // A candidate that fails verification is never published, and the running - // interface does not notice. - bad := contract.Revision{ - SchemaVersion: "0.1", ID: "R-bad", Interface: Interface, - State: contract.RevisionStateStable, - Contract: contract.RevisionContract{Type: contract.RevisionContractTypeOpenapi, Digest: contract.Digest("sha256:" + repeat("3", 64))}, - Runtime: contract.RevisionRuntime{Upstream: h.AdapterURL("R-1")}, - Intent: contract.RevisionIntent{Version: "IEI-1"}, - Policy: contract.RevisionPolicy{ - Compatibility: contract.RevisionPolicyCompatibilityBreaking, - SecurityCheck: contract.RevisionPolicySecurityCheckFailed, - }, - } - if _, _, err := h.Pipeline.Run(context.Background(), - publish.NewCandidate(bad, Operator), publish.PromotionRequest{ - AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation}, - RequestedTrafficShare: 0.1, - Approved: true, - ApprovedBy: &Operator, - }); err == nil { - t.Fatal("a candidate failing security passed verification") - } - - for i := 0; i < 5; i++ { - mustStatus(t, h.Call("c-1", http.MethodGet, "/v1/entries"), http.StatusOK, - "serving after a failed build") - } -} - -// 34.5 Experiment failure: candidate traffic removed. Runtime response: route -// to a known-good revision. -func TestContainment_ExperimentFailure(t *testing.T) { - h := New(t) - ctx := context.Background() - - loop := SeedHypothesis(t, h) - exp := DesignExperiment(t, h, loop.Hypothesis) - - _, startPolicy, err := h.Experiments.Start(ctx, exp, 2, "R-1", Operator) - if err != nil { - t.Fatal(err) - } - if err := h.Registry.PutPolicy(startPolicy); err != nil { - t.Fatal(err) - } - - // The candidate's adapter dies mid-experiment. - h.StopAdapter("R-2") - - // Stopping the experiment restores service for everyone, including - // consumers that had been allocated to the broken arm. - _, stopPolicy, err := h.Experiments.Stop(ctx, exp, 3, "R-1", Operator, "candidate adapter unreachable") - if err != nil { - t.Fatal(err) - } - if err := h.Registry.PutPolicy(stopPolicy); err != nil { - t.Fatal(err) - } - - for i := 0; i < 20; i++ { - rec := h.Call(consumerName(i), http.MethodGet, "/v1/entries") - mustStatus(t, rec, http.StatusOK, "serving after experiment rollback") - if got := rec.Header().Get("X-FLUID-Revision"); got != "R-1" { - t.Fatalf("consumer %d still routed to %q after rollback", i, got) - } - } -} - -// 34.6 Evolution store failure: runtime MUST continue using cached published -// revision configuration. -func TestContainment_EvidenceStoreFailure(t *testing.T) { - h := New(t) - - h.Drain() - if err := h.Store.Close(); err != nil { - t.Fatal(err) - } - - // Reads and writes to the store now fail. - if _, err := h.Store.Records(context.Background(), contract.KindRevision); err == nil { - t.Error("the evidence store still answers after being closed") - } - - // The registry holds its own cached view, so routing is unaffected. - for i := 0; i < 10; i++ { - rec := h.Call("c-1", http.MethodGet, "/v1/entries") - mustStatus(t, rec, http.StatusOK, "serving from cached configuration") - if rec.Header().Get("X-FLUID-Revision") != "R-1" { - t.Fatal("routing changed after the evidence store died") - } - } -} - -// 34.7 AI budget exhaustion: adaptive processes pause. Runtime effect: none. -// -// Covered by TestInvariant16_AdaptiveBudgetDoesNotAffectRuntime; this asserts -// the complementary direction, that a saturated adaptive layer cannot make the -// data plane refuse work. -func TestContainment_AdaptiveSaturation(t *testing.T) { - h := New(t) - ctx := context.Background() - - h.Experiments.SetMaxParallel(1) - loop := SeedHypothesis(t, h) - first := DesignExperiment(t, h, loop.Hypothesis) - if _, _, err := h.Experiments.Start(ctx, first, 2, "R-1", Operator); err != nil { - t.Fatal(err) - } - - second := DesignExperimentNamed(t, h, loop.Hypothesis, "E-blocked") - _, _, err := h.Experiments.Start(ctx, second, 3, "R-1", Operator) - if err == nil { - t.Fatal("the adaptive limit did not bind") - } - - for i := 0; i < 10; i++ { - mustStatus(t, h.Call("c-1", http.MethodGet, "/v1/entries"), http.StatusOK, - "serving with adaptation saturated") - } - _ = errors.Is -} - -// stallingSink never returns, standing in for a wedged evidence store. -type stallingSink struct{} - -func (s *stallingSink) Write(ctx context.Context, _ contract.FluidTelemetry) error { - <-ctx.Done() - return ctx.Err() -} - -func repeat(s string, n int) string { - out := make([]byte, 0, n*len(s)) - for i := 0; i < n; i++ { - out = append(out, s...) - } - return string(out) -} - -func consumerName(i int) string { - return "consumer-" + string(rune('a'+i%26)) -} diff --git a/conformance/suite/harness.go b/conformance/suite/harness.go deleted file mode 100644 index b869797..0000000 --- a/conformance/suite/harness.go +++ /dev/null @@ -1,310 +0,0 @@ -// Package suite is the FLUID conformance harness. -// -// It asserts the seven minimal-conformance requirements of -// FluidAPIStandards.md section 36 and the mechanically checkable subset of the -// sixteen architectural invariants in ArchitectureBlueprint.md section 55. -// -// Everything here runs with no external services and no human steps. A -// conformance claim that needed someone to run through it by hand would be a -// claim nobody re-checks after the first time. -package suite - -import ( - "context" - "crypto/ed25519" - "fmt" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - "time" - - _ "modernc.org/sqlite" - - echo "github.com/tegwick/fluid-core/examples/echo-interface" - "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/observation" - "github.com/tegwick/fluid-core/internal/policy" - "github.com/tegwick/fluid-core/internal/publish" - "github.com/tegwick/fluid-core/internal/runtime" - "github.com/tegwick/fluid-core/internal/science" - "github.com/tegwick/fluid-core/internal/signing" - "github.com/tegwick/fluid-core/internal/validate" -) - -// Interface is the fixture's interface identifier. -const Interface contract.InterfaceID = "echo-interface" - -// IntentDocument is the fixture's governing intent. -const IntentDocument = `# Interface Evolution Intent — echo-interface - -**Current operational authority mode:** -FLUID-4 - -## Mission -Serve entries to consumers, and learn from how they ask for them. -` - -// Operator is the fixture's human actor. -var Operator = contract.Actor{Type: contract.ActorTypeHuman, ID: "conformance"} - -// Harness is a fully wired FLUID deployment over the echo fixture. -type Harness struct { - T *testing.T - - Store *evidence.SQLStore - Intents *intent.Store - Registry *runtime.Registry - Gateway *runtime.Gateway - Emitter *runtime.Emitter - Ingest *observation.Ingest - Pipeline *publish.Pipeline - Validator *validate.OpenAPIValidator - Signer *signing.Signer - PublicKey ed25519.PublicKey - - Hypotheses *science.HypothesisStore - Experiments *science.ExperimentController - - adapters map[contract.RevisionID]*httptest.Server -} - -// New builds a harness with both fixture revisions published and R-1 stable. -func New(t *testing.T) *Harness { - t.Helper() - ctx := context.Background() - - store, err := evidence.OpenSQLite(ctx, filepath.Join(t.TempDir(), "conformance.db")) - if err != nil { - t.Fatalf("open evidence store: %v", err) - } - t.Cleanup(func() { _ = store.Close() }) - - intents := intent.New(store, Interface) - if _, err := intents.Put(ctx, "IEI-1", IntentDocument); err != nil { - t.Fatalf("record intent: %v", err) - } - if err := intents.SetActive(ctx, "IEI-1"); err != nil { - t.Fatalf("activate intent: %v", err) - } - - signer, pub, err := signing.GenerateKey("conformance-key") - if err != nil { - t.Fatal(err) - } - - limits := policy.DefaultLimits() - // The fixture's intent grants FLUID-4, and the loop under test promotes a - // contract adaptation, so the fixture permits that class explicitly. - limits.AllowedAdaptationClasses = append(limits.AllowedAdaptationClasses, - contract.AdaptationClassContract) - limits.RequiredMode = intent.ModeExperimental - gate := policy.NewGate(limits) - - pipeline, err := publish.New(publish.Options{ - Gate: gate, Signer: signer, Store: store, Intents: intents, - }) - if err != nil { - t.Fatal(err) - } - - salt := []byte("conformance-salt-of-sufficient-length") - ingest, err := observation.NewIngest(store, Interface, observation.DefaultRedactionPolicy(salt)) - if err != nil { - t.Fatal(err) - } - - h := &Harness{ - T: t, Store: store, Intents: intents, Pipeline: pipeline, - Validator: validate.NewOpenAPIValidator(), Signer: signer, PublicKey: pub, - Ingest: ingest, - adapters: map[contract.RevisionID]*httptest.Server{}, - } - - // A verifying registry: the router must accept only signed descriptors. - h.Registry = runtime.NewVerifiedRegistry(Interface, - signing.NewVerifier(map[string]ed25519.PublicKey{"conformance-key": pub})) - - h.adapters["R-1"] = httptest.NewServer(echo.NewR1()) - h.adapters["R-2"] = httptest.NewServer(echo.NewR2()) - t.Cleanup(func() { - for _, s := range h.adapters { - s.Close() - } - }) - - h.Emitter = runtime.NewEmitter(ingest, runtime.EmitterOptions{Buffer: 8192, Workers: 2}) - - gw, err := runtime.NewGateway(runtime.GatewayOptions{ - Interface: Interface, - Registry: h.Registry, - Resolver: runtime.NewResolver(h.Registry, true), - Connector: runtime.NewConnector(), - Emitter: h.Emitter, - Cohorts: observation.NewCohortEngine("agents", observation.DefaultRedactionPolicy(salt)), - Response: runtime.ResponsePolicy{FeedbackPath: "/v1/feedback"}, - Validator: h.Validator, - }) - if err != nil { - t.Fatal(err) - } - h.Gateway = gw - - h.Hypotheses = science.NewHypothesisStore(store, Interface) - h.Experiments = science.NewExperimentController(store, h.Hypotheses, Interface) - - h.PublishRevision("R-1", "examples/echo-interface/r1.openapi.yaml", nil) - h.PublishRevision("R-2", "examples/echo-interface/r2.openapi.yaml", - []contract.AdaptationClass{contract.AdaptationClassContract}) - h.InstallPolicy(1, "R-1", nil) - - return h -} - -// PublishRevision takes a revision through the full pipeline and registers it. -func (h *Harness) PublishRevision(id contract.RevisionID, contractPath string, classes []contract.AdaptationClass) contract.Revision { - h.T.Helper() - ctx := context.Background() - - raw := readRepoFile(h.T, contractPath) - digest := signing.Digest(raw) - - if err := h.Validator.Register(digest, raw); err != nil { - h.T.Fatalf("register contract for %s: %v", id, err) - } - - pc := contract.RevisionPolicyPolicyCheckPassed - descriptor := contract.Revision{ - SchemaVersion: "0.1", - ID: id, - Interface: Interface, - State: contract.RevisionStateStable, - Contract: contract.RevisionContract{ - Type: contract.RevisionContractTypeOpenapi, - Digest: digest, - Source: contractPath, - }, - Runtime: contract.RevisionRuntime{Upstream: h.adapters[id].URL}, - Intent: contract.RevisionIntent{Version: "IEI-1"}, - Policy: contract.RevisionPolicy{ - Compatibility: contract.RevisionPolicyCompatibilityAdditive, - SecurityCheck: contract.RevisionPolicySecurityCheckPassed, - PolicyCheck: &pc, - }, - } - - if classes == nil { - classes = []contract.AdaptationClass{contract.AdaptationClassPresentation} - } - - verified, report, err := h.Pipeline.Run(ctx, publish.NewCandidate(descriptor, Operator), - publish.PromotionRequest{ - AdaptationClasses: classes, - ComplexityDelta: 0.2, - RequestedTrafficShare: 0.2, - Approved: true, - ApprovedBy: &Operator, - }) - if err != nil { - h.T.Fatalf("publish %s: %v (%+v)", id, err, report.Stages) - } - if err := h.Pipeline.Publish(ctx, verified); err != nil { - h.T.Fatalf("record %s: %v", id, err) - } - if err := h.Registry.PutRevision(verified.Descriptor()); err != nil { - h.T.Fatalf("register %s: %v", id, err) - } - return verified.Descriptor() -} - -// InstallPolicy loads a routing policy into the registry. -func (h *Harness) InstallPolicy(generation int64, defaultRevision contract.RevisionID, rules []contract.RoutingPolicyRulesItem) { - h.T.Helper() - if rules == nil { - rules = []contract.RoutingPolicyRulesItem{} - } - if err := h.Registry.PutPolicy(contract.RoutingPolicy{ - SchemaVersion: "0.1", - Interface: Interface, - Generation: generation, - DefaultRevision: defaultRevision, - Rules: rules, - }); err != nil { - h.T.Fatalf("install policy generation %d: %v", generation, err) - } -} - -// Call issues a request through the gateway as a named consumer. -func (h *Harness) Call(consumer, method, target string) *httptest.ResponseRecorder { - h.T.Helper() - req := httptest.NewRequest(method, target, nil) - req.Header.Set("X-FLUID-Consumer", consumer) - rec := httptest.NewRecorder() - h.Gateway.ServeHTTP(rec, req) - return rec -} - -// CallPinned issues a request pinned to a specific revision. -func (h *Harness) CallPinned(consumer string, rev contract.RevisionID, method, target string) *httptest.ResponseRecorder { - h.T.Helper() - req := httptest.NewRequest(method, target, nil) - req.Header.Set("X-FLUID-Consumer", consumer) - req.Header.Set("X-FLUID-Revision", string(rev)) - rec := httptest.NewRecorder() - h.Gateway.ServeHTTP(rec, req) - return rec -} - -// Drain flushes buffered telemetry so assertions see every event. -// -// It flushes rather than closes: the harness keeps serving after a drain, and -// closing here would mean every later request emitted into a dead emitter. -func (h *Harness) Drain() { - if !h.Emitter.Flush(5 * time.Second) { - h.T.Fatal("telemetry did not reach the store within 5s") - } -} - -// Telemetry returns everything recorded for the interface. -func (h *Harness) Telemetry() []contract.FluidTelemetry { - h.T.Helper() - rows, err := h.Store.Telemetry(context.Background(), - evidence.TelemetryFilter{InterfaceID: Interface}) - if err != nil { - h.T.Fatal(err) - } - return rows -} - -// Events returns audit events for an entity. -func (h *Harness) Events(entityID string) []contract.FluidEvent { - h.T.Helper() - events, err := h.Store.Events(context.Background(), evidence.EventFilter{EntityID: entityID}) - if err != nil { - h.T.Fatal(err) - } - return events -} - -// AdapterURL returns a fixture adapter's address. -func (h *Harness) AdapterURL(rev contract.RevisionID) string { - return h.adapters[rev].URL -} - -// StopAdapter kills a fixture adapter, for failure-containment tests. -func (h *Harness) StopAdapter(rev contract.RevisionID) { - h.adapters[rev].Close() -} - -// mustStatus fails unless the response carries the expected status. -func mustStatus(t *testing.T, rec *httptest.ResponseRecorder, want int, context string) { - t.Helper() - if rec.Code != want { - t.Fatalf("%s: status %d, want %d; body %s", context, rec.Code, want, rec.Body.String()) - } -} - -var _ = fmt.Sprintf -var _ = http.MethodGet diff --git a/conformance/suite/loop.go b/conformance/suite/loop.go deleted file mode 100644 index 3aa9dd0..0000000 --- a/conformance/suite/loop.go +++ /dev/null @@ -1,321 +0,0 @@ -package suite - -import ( - "context" - "fmt" - "net/http" - "testing" - "time" - - "github.com/tegwick/fluid-core/internal/contract" - "github.com/tegwick/fluid-core/internal/fitness" - "github.com/tegwick/fluid-core/internal/intent" - "github.com/tegwick/fluid-core/internal/observation" - "github.com/tegwick/fluid-core/internal/policy" - "github.com/tegwick/fluid-core/internal/promotion" - "github.com/tegwick/fluid-core/internal/science" -) - -// Loop records the artifacts one pass of the adaptation loop produced. -type Loop struct { - Pressure contract.PressureID - Hypothesis contract.HypothesisID - Experiment contract.ExperimentID - Candidate contract.RevisionID - Evaluation fitness.Evaluation - Decision promotion.Decision -} - -// GeneratePressure drives the traffic that makes R-1's shortcoming visible. -// -// Several independent consumers each fetch the whole collection repeatedly -// within one task, which is the Blueprint section 33 shape: the interface is -// making them assemble something it could have handed them. -func GeneratePressure(t *testing.T, h *Harness) { - t.Helper() - for c := 0; c < 5; c++ { - consumer := fmt.Sprintf("agent-%d", c) - for chain := 0; chain < 4; chain++ { - for i := 0; i < 3; i++ { - rec := h.Call(consumer, http.MethodGet, "/v1/entries") - mustStatus(t, rec, http.StatusOK, "pressure traffic") - } - } - } -} - -// DetectPressure classifies recorded telemetry and records what it finds. -func DetectPressure(t *testing.T, h *Harness) contract.PressureID { - t.Helper() - ctx := context.Background() - - classifier := observation.NewClassifier( - observation.DefaultClassifierOptions(), observation.NewTopologyAnalyzer()) - findings := classifier.Classify(h.Telemetry()) - if len(findings) == 0 { - t.Fatal("no pressure detected from traffic that plainly shows it") - } - - registry := observation.NewPressureRegistry(h.Store, Interface) - recorded, err := registry.RecordAll(ctx, findings) - if err != nil { - t.Fatal(err) - } - - for _, p := range recorded { - if p.Class == contract.PressureClassSuccessfulButInefficientUsage { - return p.ID - } - } - t.Fatalf("inefficient usage was not among the findings: %v", recorded) - return "" -} - -// SeedHypothesis creates and prepares a hypothesis explaining the pressure. -func SeedHypothesis(t *testing.T, h *Harness) Loop { - t.Helper() - ctx := context.Background() - - hypothesis := contract.FluidHypothesis{ - ID: "H-1", - Title: "latest entry is a first-class consumer concept", - Observation: contract.FluidHypothesisObservation{ - Summary: "consumers fetch the whole collection repeatedly to find the newest entry", - EvidenceRefs: []contract.EvidenceRef{"topology:GET /v1/entries"}, - }, - Pressure: contract.FluidHypothesisPressure{ - Classes: []contract.PressureClass{contract.PressureClassSuccessfulButInefficientUsage}, - }, - Explanation: contract.FluidHypothesisExplanation{ - Claim: "the collection resource does not name a concept consumers hold, so they assemble it themselves", - }, - ProposedAdaptation: contract.FluidHypothesisProposedAdaptation{ - Class: contract.AdaptationClassContract, - Summary: "add an explicit latest-entry resource", - }, - ExpectedOutcomes: []contract.ExpectedOutcome{{ - Metric: fitness.MetricRequestsPerTask, - Target: 1.2, - Direction: contract.ExpectedOutcomeDirectionLower, - }}, - Guardrails: []contract.Guardrail{{ - Metric: fitness.MetricErrorRate, Operator: contract.GuardrailOperatorLte, Threshold: 0.01, - }}, - SuccessCriteria: contract.FluidHypothesisSuccessCriteria{ - Expression: "requests_per_completed_task <= 1.2 with no guardrail violation", - }, - Complexity: contract.FluidHypothesisComplexity{ - ExpectedDelta: contract.ComplexityDelta{OperationCount: ptr(1.0)}, - }, - Risk: contract.FluidHypothesisRisk{Level: contract.FluidHypothesisRiskLevelLOW}, - } - - if _, err := h.Hypotheses.Create(ctx, hypothesis, Operator); err != nil { - t.Fatal(err) - } - for _, s := range []contract.FluidHypothesisState{ - contract.FluidHypothesisStateREADY, - contract.FluidHypothesisStatePRIORITIZED, - contract.FluidHypothesisStateDESIGNING, - } { - if _, err := h.Hypotheses.Transition(ctx, "H-1", s, Operator, "advancing the loop"); err != nil { - t.Fatal(err) - } - } - if err := h.Hypotheses.AttachRevision(ctx, "H-1", "R-2", Operator); err != nil { - t.Fatal(err) - } - - return Loop{Hypothesis: "H-1", Candidate: "R-2"} -} - -// DesignExperiment creates the 90/10 experiment the Blueprint slice calls for. -func DesignExperiment(t *testing.T, h *Harness, hypothesis contract.HypothesisID) contract.ExperimentID { - t.Helper() - return DesignExperimentNamed(t, h, hypothesis, "E-1") -} - -// DesignExperimentNamed creates an experiment with a chosen id. -func DesignExperimentNamed(t *testing.T, h *Harness, hypothesis contract.HypothesisID, id contract.ExperimentID) contract.ExperimentID { - t.Helper() - ctx := context.Background() - - e := contract.FluidExperiment{ - ID: id, - HypothesisRefs: []contract.HypothesisID{hypothesis}, - ControlRevision: "R-1", - CandidateRevisions: []contract.RevisionID{"R-2"}, - Cohorts: []contract.CohortID{"agents"}, - Allocation: map[string]contract.UnitInterval{"control": 0.9, "candidate": 0.1}, - Metrics: contract.FluidExperimentMetrics{ - Primary: []string{fitness.MetricRequestsPerTask}, - Guardrails: []string{fitness.MetricErrorRate}, - }, - StopConditions: []string{"hard_guardrail_violation", "manual_stop"}, - } - if _, err := h.Experiments.Design(ctx, e, Operator); err != nil { - t.Fatal(err) - } - return id -} - -// RunFullLoop executes the complete section 50 vertical slice. -// -// Two deterministic revisions, explicit routing, telemetry, one cohort -// dimension, pressure detection, a hypothesis, a candidate revision, a bounded -// experiment, fitness comparison, promotion, and a complete audit trail — with -// no human steps. -func RunFullLoop(t *testing.T, h *Harness) Loop { - t.Helper() - ctx := context.Background() - - // 1-3. Traffic, telemetry, pressure. - GeneratePressure(t, h) - h.Drain() - pressureID := DetectPressure(t, h) - - // 4. Hypothesis explaining it. - loop := SeedHypothesis(t, h) - loop.Pressure = pressureID - - // 5. Link the evidence to the explanation. - registry := observation.NewPressureRegistry(h.Store, Interface) - if err := registry.LinkHypothesis(ctx, pressureID, loop.Hypothesis); err != nil { - t.Fatal(err) - } - - // 6. A bounded experiment, enacted through routing policy. - expID := DesignExperiment(t, h, loop.Hypothesis) - loop.Experiment = expID - - _, startPolicy, err := h.Experiments.Start(ctx, expID, 2, "R-1", Operator) - if err != nil { - t.Fatal(err) - } - if err := h.Registry.PutPolicy(startPolicy); err != nil { - t.Fatal(err) - } - - // 7. Measure both arms. Consumers are pinned so each arm gets the shape it - // is meant to demonstrate; the allocation itself is exercised separately in - // the resolver's own tests. - observed := measureBothArms(t, h) - - // 8. Fitness comparison against the declared criteria. - specs := []fitness.MetricSpec{ - {Name: fitness.MetricRequestsPerTask, Role: fitness.RolePrimary, - Direction: fitness.Lower, Target: ptr(1.2)}, - {Name: fitness.MetricErrorRate, Role: fitness.RoleGuardrail, - Direction: fitness.Lower, Threshold: ptr(0.01)}, - } - evaluator := fitness.NewEvaluator() - evaluator.MinSamples = 10 - loop.Evaluation = evaluator.Evaluate("R-1", "R-2", observed.window, specs, observed.observations) - - // 9. Stop the experiment and record its conclusion. - _, stopPolicy, err := h.Experiments.Stop(ctx, expID, 3, "R-1", Operator, "measurement window elapsed") - if err != nil { - t.Fatal(err) - } - if err := h.Registry.PutPolicy(stopPolicy); err != nil { - t.Fatal(err) - } - if _, err := h.Experiments.Finalize(ctx, expID, "R-2", Operator, - fmt.Sprintf("fitness verdict %s", loop.Evaluation.Verdict), nil); err != nil { - t.Fatal(err) - } - - // 10. Record the hypothesis outcome. - status := contract.FluidHypothesisOutcomeStatusREFUTED - if loop.Evaluation.Verdict == fitness.VerdictSucceeded { - status = contract.FluidHypothesisOutcomeStatusCONFIRMED - } - if _, err := h.Hypotheses.RecordOutcome(ctx, loop.Hypothesis, status, - fmt.Sprintf("verdict %s: %v", loop.Evaluation.Verdict, loop.Evaluation.Reasons), nil, Operator); err != nil { - t.Fatal(err) - } - - // 11. Promotion, through the deterministic gate. - loop.Decision = decide(t, h, loop) - return loop -} - -type measurement struct { - observations []fitness.Observation - window fitness.Window -} - -// measureBothArms drives the two revisions and derives their metrics. -func measureBothArms(t *testing.T, h *Harness) measurement { - t.Helper() - start := time.Now().Add(-time.Hour) - - // R-1: three calls per task, the shape the pressure described. - for c := 0; c < 12; c++ { - consumer := fmt.Sprintf("control-%d", c) - for i := 0; i < 3; i++ { - mustStatus(t, h.CallPinned(consumer, "R-1", http.MethodGet, "/v1/entries"), - http.StatusOK, "control arm") - } - } - // R-2: one call per task, using the resource the hypothesis proposed. - for c := 0; c < 12; c++ { - consumer := fmt.Sprintf("candidate-%d", c) - mustStatus(t, h.CallPinned(consumer, "R-2", http.MethodGet, "/v1/entries/latest"), - http.StatusOK, "candidate arm") - } - h.Drain() - - window := fitness.Window{Start: start} - return measurement{ - observations: fitness.NewMeasurer().Measure(h.Telemetry(), window), - window: window, - } -} - -func decide(t *testing.T, h *Harness, loop Loop) promotion.Decision { - t.Helper() - ctx := context.Background() - - descriptor, err := h.Registry.Revision(loop.Candidate) - if err != nil { - t.Fatal(err) - } - governing, err := h.Intents.GoverningIntent(ctx, loop.Candidate) - if err != nil { - t.Fatal(err) - } - - limits := policy.DefaultLimits() - limits.AllowedAdaptationClasses = append(limits.AllowedAdaptationClasses, - contract.AdaptationClassContract) - limits.RequiredMode = intent.ModeExperimental - - controller := promotion.NewController(h.Store, policy.NewGate(limits)) - d, err := controller.Decide(ctx, promotion.Request{ - Revision: loop.Candidate, - Outcome: promotion.Promote, - Reason: "the candidate met its primary target with no guardrail breach", - Actor: Operator, - Experiment: loop.Experiment, - Hypotheses: []contract.HypothesisID{loop.Hypothesis}, - Evaluation: &loop.Evaluation, - GateInput: &policy.Input{ - Descriptor: descriptor, - GoverningMode: governing.Mode, - AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassContract}, - ComplexityDelta: 0.2, - RequestedTrafficShare: 0.2, - Approved: true, - ApprovedBy: &Operator, - }, - }) - if err != nil { - t.Fatalf("promotion refused: %v", err) - } - return d -} - -func ptr[T any](v T) *T { return &v } - -var _ = science.CanTransition diff --git a/conformance/suite/repo.go b/conformance/suite/repo.go deleted file mode 100644 index 2b1c6c0..0000000 --- a/conformance/suite/repo.go +++ /dev/null @@ -1,35 +0,0 @@ -package suite - -import ( - "os" - "path/filepath" - "testing" -) - -// readRepoFile reads a path relative to the repository root. -// -// Tests run from their own package directory, so fixture paths are resolved -// against the module root rather than the working directory. Hard-coding -// "../.." would break the moment the suite moved. -func readRepoFile(t *testing.T, rel string) []byte { - t.Helper() - - dir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - raw, err := os.ReadFile(filepath.Join(dir, rel)) - if err != nil { - t.Fatalf("read %s: %v", rel, err) - } - return raw - } - parent := filepath.Dir(dir) - if parent == dir { - t.Fatalf("could not find the repository root above %s", dir) - } - dir = parent - } -} diff --git a/conformance/suite/slice_test.go b/conformance/suite/slice_test.go deleted file mode 100644 index 8de9816..0000000 --- a/conformance/suite/slice_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package suite - -import ( - "context" - "strings" - "testing" - - "github.com/tegwick/fluid-core/internal/contract" - "github.com/tegwick/fluid-core/internal/fitness" - "github.com/tegwick/fluid-core/internal/intent" - "github.com/tegwick/fluid-core/internal/policy" - "github.com/tegwick/fluid-core/internal/promotion" -) - -// TestFirstVerticalSlice is the whole point of the conformance suite. -// -// ArchitectureBlueprint.md section 50 lists eleven things the first FLUID -// deployment must demonstrate, and says plainly what success means: -// -// The first success criterion is not autonomous coding. -// It is proving that the revision-experiment-fitness loop works cleanly -// and safely. -// -// This test runs all eleven with no human steps. -func TestFirstVerticalSlice(t *testing.T) { - h := New(t) - loop := RunFullLoop(t, h) - - // 1. Two deterministic revisions. - for _, id := range []contract.RevisionID{"R-1", "R-2"} { - if _, err := h.Registry.Revision(id); err != nil { - t.Fatalf("revision %s is not published: %v", id, err) - } - } - - // 2. Explicit revision routing. - policy, err := h.Registry.Policy() - if err != nil { - t.Fatalf("no routing policy: %v", err) - } - if policy.DefaultRevision == "" { - t.Error("the routing policy names no default revision") - } - - // 3. Telemetry. - telemetry := h.Telemetry() - if len(telemetry) == 0 { - t.Fatal("no telemetry recorded") - } - - // 4. One consumer cohort dimension. - var sawCohort bool - for _, ev := range telemetry { - if ev.Cohort != nil && *ev.Cohort != "" { - sawCohort = true - break - } - } - if !sawCohort { - t.Error("no telemetry carries a cohort assignment") - } - - // 5. Pressure detection. - if loop.Pressure == "" { - t.Fatal("no pressure was detected") - } - - // 6. A hypothesis, linked to that pressure. - hyp, err := h.Hypotheses.Get(context.Background(), loop.Hypothesis) - if err != nil { - t.Fatal(err) - } - if hyp.Explanation.Claim == "" { - t.Error("the hypothesis offers no explanation") - } - - // 7. A candidate revision, claimed by that hypothesis. - var claimsCandidate bool - for _, r := range hyp.CandidateRevisionRefs { - if r == loop.Candidate { - claimsCandidate = true - } - } - if !claimsCandidate { - t.Errorf("the hypothesis does not claim %s", loop.Candidate) - } - - // 8. A controlled 90/10 experiment. - exp, err := h.Experiments.Get(context.Background(), loop.Experiment) - if err != nil { - t.Fatal(err) - } - if exp.Allocation["control"] != 0.9 || exp.Allocation["candidate"] != 0.1 { - t.Errorf("allocation was %v, want a 90/10 split", exp.Allocation) - } - - // 9. Fitness comparison. - if loop.Evaluation.Verdict != fitness.VerdictSucceeded { - t.Fatalf("verdict %s: %v", loop.Evaluation.Verdict, loop.Evaluation.Reasons) - } - primary := loop.Evaluation.PrimaryResults() - if len(primary) != 1 { - t.Fatalf("expected one primary metric, got %d", len(primary)) - } - // The candidate should show the reduction the hypothesis predicted. - if primary[0].Current >= primary[0].Baseline { - t.Errorf("requests per task did not fall: %v -> %v", - primary[0].Baseline, primary[0].Current) - } - t.Logf("requests per completed task: %.2f -> %.2f (target %.2f)", - primary[0].Baseline, primary[0].Current, *primary[0].Target) - - // 10. Promotion. - if loop.Decision.Outcome != promotion.Promote { - t.Errorf("decision was %s, want PROMOTE", loop.Decision.Outcome) - } - if loop.Decision.Override { - t.Error("the promotion was an override; the evidence should have carried it") - } - - // 11. A complete audit trail. - // - // Every stage of the loop must have left a reconstructable record. This is - // the assertion that would fail first if any part of the framework started - // deciding things without saying so. - events := h.Events(string(loop.Candidate)) - required := map[string]bool{ - "SIGN_PASSED": false, - "POLICY_CHECK_PASSED": false, - "INTENT_BOUND": false, - "REVISION_PUBLISHED": false, - "PROMOTION_DECIDED_PROMOTE": false, - } - for _, ev := range events { - if _, tracked := required[ev.EventType]; tracked { - required[ev.EventType] = true - } - } - for name, seen := range required { - if !seen { - t.Errorf("the audit trail is missing %s", name) - } - } - - // The pressure, hypothesis and experiment each left their own trail. - for _, entity := range []string{ - string(loop.Pressure), string(loop.Hypothesis), string(loop.Experiment), - } { - if len(h.Events(entity)) == 0 { - t.Errorf("%s left no audit events", entity) - } - } - - t.Logf("slice complete: %s -> %s -> %s -> %s (%s)", - loop.Pressure, loop.Hypothesis, loop.Experiment, loop.Candidate, loop.Decision.Outcome) -} - -// TestSliceIsReproducible runs the loop twice over fresh state and requires the -// same conclusion both times. -// -// A loop whose verdict depended on run order would be measuring the harness -// rather than the interface. -func TestSliceIsReproducible(t *testing.T) { - var verdicts []fitness.Verdict - var ratios []float64 - - for i := 0; i < 2; i++ { - h := New(t) - loop := RunFullLoop(t, h) - verdicts = append(verdicts, loop.Evaluation.Verdict) - ratios = append(ratios, loop.Evaluation.PrimaryResults()[0].Current) - } - - if verdicts[0] != verdicts[1] { - t.Errorf("verdict varied between runs: %s then %s", verdicts[0], verdicts[1]) - } - if ratios[0] != ratios[1] { - t.Errorf("measured ratio varied between runs: %v then %v", ratios[0], ratios[1]) - } -} - -// TestSliceRefusesPromotionWithoutEvidence confirms the loop cannot be -// short-circuited: the same candidate, promoted without its experiment, is -// refused. -func TestSliceRefusesPromotionWithoutEvidence(t *testing.T) { - h := New(t) - - descriptor, err := h.Registry.Revision("R-2") - if err != nil { - t.Fatal(err) - } - governing, err := h.Intents.GoverningIntent(context.Background(), "R-2") - if err != nil { - t.Fatal(err) - } - - limits := policy.DefaultLimits() - limits.AllowedAdaptationClasses = append(limits.AllowedAdaptationClasses, - contract.AdaptationClassContract) - limits.RequiredMode = intent.ModeExperimental - - _, err = promotion.NewController(h.Store, policy.NewGate(limits)).Decide(context.Background(), promotion.Request{ - Revision: "R-2", - Outcome: promotion.Promote, - Reason: "it looks better", - Actor: Operator, - GateInput: &policy.Input{ - Descriptor: descriptor, - GoverningMode: governing.Mode, - AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassContract}, - RequestedTrafficShare: 0.2, - Approved: true, - ApprovedBy: &Operator, - }, - }) - if err == nil { - t.Fatal("a candidate was promoted with no fitness evidence") - } - if !strings.Contains(err.Error(), "fitness") { - t.Errorf("refusal does not cite missing evidence: %v", err) - } -} diff --git a/docs/integration-guide.md b/docs/integration-guide.md deleted file mode 100644 index ecd7b8e..0000000 --- a/docs/integration-guide.md +++ /dev/null @@ -1,137 +0,0 @@ -# Putting an existing API behind fluid-core - -fluid-core attaches **out of process** (ADR-0002). Your API contributes no code, -imports no library, and may be written in any stack. Integration is over a wire -contract, so the fact that fluid-core is written in Go is not something you need -to care about. - -## What you need - -1. **A running service.** Anything that speaks HTTP. This becomes an *adapter*. -2. **A contract.** OpenAPI 3.1 today; the validator interface admits others. -3. **An interface evolution intent.** The governance document that bounds how - the interface may change. - -Nothing else. In particular you do not need a Kubernetes cluster, a message -broker, or Postgres — SQLite is the default evidence store. - -## The five minutes version - -```bash -export FLUID_INTERFACE=my-api -export FLUID_STORE=$PWD/fluid.db - -# 1. Record the intent that governs this interface. -fluid intent put --version IEI-1 --file InterfaceEvolutionIntent.md --activate - -# 2. Publish a revision pointing at your existing service. -fluid revision publish --file r1.yaml --ephemeral-key \ - --adaptation-classes presentation --approved-by "$USER" --traffic-share 1.0 - -# 3. Route traffic to it. -fluid policy put --file routing-policy.yaml -``` - -A revision descriptor is small: - -```yaml -revision: - schema_version: "0.1" - id: "R-1" - interface: "my-api" - state: "stable" - contract: - type: "openapi" - digest: "sha256:" - runtime: - upstream: "http://my-existing-service:8080" # your service, unchanged - timeout_ms: 5000 - intent: - version: "IEI-1" - policy: - compatibility: "additive" - security_check: "passed" -``` - -`runtime.upstream` is the whole integration. Everything else is metadata about -what that upstream is allowed to be. - -## What you get immediately - -- **Revision identity.** Every response names the revision that served it. -- **Telemetry.** Requests, errors, call sequences and adoption, redacted before - storage. -- **Contract enforcement.** Undeclared paths, methods and fields are refused - rather than proxied. -- **An audit trail.** `fluid audit trace R-1` reconstructs how a revision came - to exist and what happened to it. - -That is FLUID-0 (Instrumented) conformance, and it needs no AI and no change to -your service. - -## What you should decide deliberately - -**The pseudonymization salt.** Consumer identities are pseudonymized with HMAC. -The salt must be stable for the life of the interface — rotating it makes the -same consumer look like a new one and breaks every longitudinal comparison. -Store it where you store secrets, not in the repository. - -**The authority mode.** Your intent document declares one, `FLUID-0` through -`FLUID-6`. This is a statement about authority, not maturity: a high-assurance -interface may deliberately stay at FLUID-2 forever. The unfilled template is -refused rather than defaulted, precisely so nobody inherits a permissive mode by -accident. - -**Your complexity budget.** `policy.DefaultLimits()` permits only presentation -and implementation adaptations, refuses breaking changes, and caps exposure at -25%. If your interface needs more, say so explicitly in the intent rather than -widening the default. - -## Adding a second revision - -This is where FLUID earns its keep. Publish a second revision pointing at a -second adapter — a different build, a different service, a different language — -and let the experiment controller split traffic between them: - -```bash -fluid revision publish --file r2.yaml --ephemeral-key ... -fluid experiment design --file experiment.yaml -fluid experiment start E-1 --generation 2 --default-revision R-1 --policy-out rp.json -fluid policy put --file rp.json -``` - -The controller does not touch traffic. It emits a routing policy for you to -install, which is what makes an experiment interruptible: stopping one replaces -a document rather than unwinding anything. - -## Signing - -`--ephemeral-key` is for development. It generates a throwaway key and warns -that revisions signed with it will not verify after a restart, which is exactly -what you want to hear before it happens in production. - -In production, generate a key pair, keep the private half in your secret store, -and configure the gateway's registry with the public half. The router accepts -only descriptors that verify — an unsigned or tampered descriptor is refused, -including one that was signed and then edited. - -## What fluid-core will not do for you - -- **It will not modify your backend.** Where a candidate needs capability the - backend does not have, FLUID emits a structured requirement and stops. The - backend owner accepts, plans, or declares it out of scope. -- **It will not decide what "better" means.** There is no universal fitness - scalar. You declare primary metrics and guardrails; the evaluator compares - against what you declared and refuses to infer criteria from the data. -- **It will not promote on thin evidence.** A comparison below the sample floor - is inconclusive, not successful. You can override that with an acknowledged - flag; the override is recorded as one. - -## Reference - -- `examples/echo-interface/` — the two-revision fixture the conformance suite - drives, and the smallest complete example. -- `conformance/suite/` — every conformance requirement asserted as a test. - `TestFirstVerticalSlice` is the whole loop in one function. -- `spec/` — the normative documents. When this guide and the spec disagree, the - spec is right and this guide is a bug. diff --git a/examples/echo-interface/README.md b/examples/echo-interface/README.md deleted file mode 100644 index f5bb095..0000000 --- a/examples/echo-interface/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# echo-interface - -The smallest honest reproduction of the `ArchitectureBlueprint.md` §33 worked -example, used as the conformance fixture. - -Two revisions of one interface over the same backend data: - -| Revision | Shape | Requests per completed task | -|---|---|---| -| **R-1** | `GET /v1/entries` only — consumers list everything and filter locally | ~3 | -| **R-2** | adds `GET /v1/entries/latest` — the concept consumers actually wanted | 1 | - -R-1 is not a strawman. It is the interface a careful designer produces before -they have seen how it is used: a clean collection resource with no special -cases. The pressure it generates is the point — consumers repeatedly fetching -a collection to discard all but one item is the evidence that "latest" is a -first-class concept the contract failed to name. - -The fixture has no external dependencies and runs in CI. It exists so the -revision–experiment–fitness loop is proven mechanically before a real workload -depends on it. diff --git a/examples/echo-interface/adapter.go b/examples/echo-interface/adapter.go deleted file mode 100644 index ea7c5c9..0000000 --- a/examples/echo-interface/adapter.go +++ /dev/null @@ -1,67 +0,0 @@ -// Package echo provides the conformance fixture adapters. -// -// Two revisions over the same data: R-1 exposes only a collection, R-2 adds the -// convenience resource. Both are ordinary deterministic HTTP handlers, which is -// the point — an adapter is a normal service, and fluid-core sits in front of -// it without asking anything of it. -package echo - -import ( - "encoding/json" - "net/http" - "sort" - "time" -) - -// Entry is one record the fixture serves. -type Entry struct { - ID string `json:"id"` - Title string `json:"title"` - CreatedAt time.Time `json:"created_at"` -} - -// Data returns a deterministic set of entries. -// -// Fixed timestamps rather than time.Now: a conformance suite whose fixture -// changes between runs cannot distinguish a regression from the clock. -func Data() []Entry { - base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) - return []Entry{ - {ID: "e-1", Title: "the river reached the forge", CreatedAt: base}, - {ID: "e-2", Title: "nine doors stayed honest", CreatedAt: base.Add(24 * time.Hour)}, - {ID: "e-3", Title: "the reviewing side", CreatedAt: base.Add(48 * time.Hour)}, - } -} - -// NewR1 returns the collection-only adapter. -// -// A consumer wanting the newest entry must fetch the whole collection, sort it -// and discard the rest. That is the interface pressure the fixture generates. -func NewR1() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("/v1/entries", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, Data()) - }) - return mux -} - -// NewR2 returns the adapter with the convenience resource added. -func NewR2() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("/v1/entries", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, Data()) - }) - mux.HandleFunc("/v1/entries/latest", func(w http.ResponseWriter, r *http.Request) { - entries := Data() - sort.Slice(entries, func(i, j int) bool { - return entries[i].CreatedAt.After(entries[j].CreatedAt) - }) - writeJSON(w, entries[0]) - }) - return mux -} - -func writeJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(v) -} diff --git a/examples/echo-interface/r1.openapi.yaml b/examples/echo-interface/r1.openapi.yaml deleted file mode 100644 index 19468d8..0000000 --- a/examples/echo-interface/r1.openapi.yaml +++ /dev/null @@ -1,12 +0,0 @@ -openapi: "3.1.0" -info: - title: echo-interface - version: "1" -paths: - /v1/entries: - get: - operationId: listEntries - parameters: - - name: limit - in: query - schema: {type: integer, minimum: 1, maximum: 100} diff --git a/examples/echo-interface/r2.openapi.yaml b/examples/echo-interface/r2.openapi.yaml deleted file mode 100644 index 254ea24..0000000 --- a/examples/echo-interface/r2.openapi.yaml +++ /dev/null @@ -1,15 +0,0 @@ -openapi: "3.1.0" -info: - title: echo-interface - version: "2" -paths: - /v1/entries: - get: - operationId: listEntries - parameters: - - name: limit - in: query - schema: {type: integer, minimum: 1, maximum: 100} - /v1/entries/latest: - get: - operationId: latestEntry diff --git a/internal/runtime/telemetry.go b/internal/runtime/telemetry.go index ca0ede5..974de8f 100644 --- a/internal/runtime/telemetry.go +++ b/internal/runtime/telemetry.go @@ -29,7 +29,6 @@ type Emitter struct { dropped atomic.Int64 written atomic.Int64 failed atomic.Int64 - inflight atomic.Int64 stopOnce sync.Once done chan struct{} wg sync.WaitGroup @@ -77,7 +76,6 @@ func NewEmitter(sink Sink, opts EmitterOptions) *Emitter { func (e *Emitter) Emit(ev contract.FluidTelemetry) { select { case e.ch <- ev: - e.inflight.Add(1) default: e.dropped.Add(1) } @@ -91,13 +89,25 @@ func (e *Emitter) run(timeout time.Duration) { if !ok { return } - e.deliver(ev, timeout) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + if err := e.sink.Write(ctx, ev); err != nil { + e.failed.Add(1) + } else { + e.written.Add(1) + } + cancel() case <-e.done: // Drain what is already buffered, then stop. for { select { case ev := <-e.ch: - e.deliver(ev, timeout) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + if err := e.sink.Write(ctx, ev); err != nil { + e.failed.Add(1) + } else { + e.written.Add(1) + } + cancel() default: return } @@ -106,39 +116,6 @@ func (e *Emitter) run(timeout time.Duration) { } } -// deliver writes one event and settles its in-flight accounting. -func (e *Emitter) deliver(ev contract.FluidTelemetry, timeout time.Duration) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - if err := e.sink.Write(ctx, ev); err != nil { - e.failed.Add(1) - } else { - e.written.Add(1) - } - cancel() - e.inflight.Add(-1) -} - -// Flush waits for queued telemetry to reach the sink without stopping delivery. -// -// It exists for tests and for operational tooling that needs to read back what -// it just emitted. Close would also flush, but closing an emitter that is still -// serving traffic silently drops everything emitted afterwards -- which is the -// kind of bug that makes a later measurement quietly wrong rather than loudly -// broken. -// -// It returns false if the deadline passes with work still outstanding, so a -// caller can tell a slow sink from an empty one. -func (e *Emitter) Flush(timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if e.inflight.Load() == 0 && len(e.ch) == 0 { - return true - } - time.Sleep(time.Millisecond) - } - return e.inflight.Load() == 0 && len(e.ch) == 0 -} - // Close stops delivery after draining the buffer. func (e *Emitter) Close() { e.stopOnce.Do(func() { close(e.done) }) diff --git a/internal/signing/signing.go b/internal/signing/signing.go index 6c0300c..fdb548f 100644 --- a/internal/signing/signing.go +++ b/internal/signing/signing.go @@ -9,16 +9,12 @@ package signing import ( "crypto/ed25519" - "crypto/sha256" "encoding/base64" - "encoding/hex" "encoding/json" "errors" "fmt" "sort" "strings" - - "github.com/tegwick/fluid-core/internal/contract" ) // Algorithm is the only signature algorithm FLUID defines. @@ -199,12 +195,6 @@ func (v *Verifier) Verify(document any, sig *Signature) error { return nil } -// Digest content-addresses a raw artifact such as a contract document. -func Digest(raw []byte) contract.Digest { - sum := sha256.Sum256(raw) - return contract.Digest("sha256:" + hex.EncodeToString(sum[:])) -} - // GenerateKey produces a new signing key pair. Intended for development and // tests; production keys should come from the deployment's key management. func GenerateKey(keyID string) (*Signer, ed25519.PublicKey, error) { diff --git a/internal/validate/jsonschema.go b/internal/validate/jsonschema.go deleted file mode 100644 index 3200b26..0000000 --- a/internal/validate/jsonschema.go +++ /dev/null @@ -1,393 +0,0 @@ -// Package validate implements deterministic contract validation. -// -// ArchitectureBlueprint.md section 5.4 requires each revision to have an -// identifiable contract the gateway can enforce, and FluidAPIStandards.md -// section 36 makes "a deterministic API contract" the first minimal-conformance -// requirement. Validation must therefore be a pure function of the request and -// the contract: no inference, no model, no defaults invented at runtime. -// -// The JSON Schema support here is a documented subset rather than a complete -// implementation. The subset covers what an interface contract actually -// constrains — types, required members, enums, bounds and nesting — and -// anything outside it is reported as unsupported rather than silently passed. -// A validator that quietly ignores a keyword it does not understand is worse -// than no validator, because it reports success. -package validate - -import ( - "errors" - "fmt" - "math" - "sort" - "strings" -) - -// Schema is the supported JSON Schema subset. -type Schema struct { - Type any `json:"type,omitempty" yaml:"type,omitempty"` - Properties map[string]*Schema `json:"properties,omitempty" yaml:"properties,omitempty"` - Required []string `json:"required,omitempty" yaml:"required,omitempty"` - Items *Schema `json:"items,omitempty" yaml:"items,omitempty"` - Enum []any `json:"enum,omitempty" yaml:"enum,omitempty"` - Format string `json:"format,omitempty" yaml:"format,omitempty"` - AdditionalProperties *bool `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` - - Minimum *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"` - Maximum *float64 `json:"maximum,omitempty" yaml:"maximum,omitempty"` - MinLength *int `json:"minLength,omitempty" yaml:"minLength,omitempty"` - MaxLength *int `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` - MinItems *int `json:"minItems,omitempty" yaml:"minItems,omitempty"` - MaxItems *int `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` - - Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` - - // Nullable follows OpenAPI 3.0; 3.1 uses a type union instead. Both are - // accepted because contracts in the wild use both. - Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` -} - -// Violation is one contract breach, named precisely enough to fix. -type Violation struct { - // Path locates the offending value, such as "body.entry.title". - Path string - // Message says what is wrong in terms the consumer can act on. - Message string -} - -func (v Violation) String() string { - if v.Path == "" { - return v.Message - } - return v.Path + ": " + v.Message -} - -// Result collects violations from one validation. -type Result struct { - Violations []Violation -} - -// OK reports whether validation passed. -func (r *Result) OK() bool { return len(r.Violations) == 0 } - -// Error renders every violation, most specific path first. -func (r *Result) Error() string { - if r.OK() { - return "" - } - sorted := make([]Violation, len(r.Violations)) - copy(sorted, r.Violations) - sort.Slice(sorted, func(i, j int) bool { - if sorted[i].Path != sorted[j].Path { - return sorted[i].Path < sorted[j].Path - } - return sorted[i].Message < sorted[j].Message - }) - - parts := make([]string, len(sorted)) - for i, v := range sorted { - parts[i] = v.String() - } - return strings.Join(parts, "; ") -} - -// FirstPath returns the path of the first violation, for error reporting. -func (r *Result) FirstPath() string { - if r.OK() { - return "" - } - best := r.Violations[0].Path - for _, v := range r.Violations[1:] { - if v.Path < best { - best = v.Path - } - } - return best -} - -func (r *Result) add(path, format string, args ...any) { - r.Violations = append(r.Violations, Violation{Path: path, Message: fmt.Sprintf(format, args...)}) -} - -// ErrUnsupported reports a schema keyword outside the supported subset. -var ErrUnsupported = errors.New("unsupported schema construct") - -// Resolver resolves $ref pointers within a document. -type Resolver interface { - Resolve(ref string) (*Schema, error) -} - -// Validate checks a decoded JSON value against a schema. -func Validate(value any, schema *Schema, resolver Resolver) *Result { - r := &Result{} - validateValue(value, schema, "", r, resolver, 0) - return r -} - -// maxDepth bounds recursion so a cyclic $ref cannot hang the request path. -const maxDepth = 64 - -func validateValue(value any, schema *Schema, path string, r *Result, resolver Resolver, depth int) { - if schema == nil { - return - } - if depth > maxDepth { - r.add(path, "schema nesting exceeds %d levels; refusing to recurse further", maxDepth) - return - } - - if schema.Ref != "" { - if resolver == nil { - r.add(path, "schema uses $ref %q but no resolver is configured", schema.Ref) - return - } - resolved, err := resolver.Resolve(schema.Ref) - if err != nil { - r.add(path, "cannot resolve $ref %q: %v", schema.Ref, err) - return - } - validateValue(value, resolved, path, r, resolver, depth+1) - return - } - - if value == nil { - if schema.Nullable || typeAllows(schema, "null") { - return - } - if schema.Type != nil { - r.add(path, "must not be null") - } - return - } - - if schema.Type != nil && !matchesType(value, schema) { - r.add(path, "expected %s, got %s", describeType(schema.Type), goTypeName(value)) - return - } - - if len(schema.Enum) > 0 && !inEnum(value, schema.Enum) { - r.add(path, "value %v is not one of %v", value, schema.Enum) - } - - switch v := value.(type) { - case map[string]any: - validateObject(v, schema, path, r, resolver, depth) - case []any: - validateArray(v, schema, path, r, resolver, depth) - case string: - validateString(v, schema, path, r) - case float64: - validateNumber(v, schema, path, r) - } -} - -func validateObject(obj map[string]any, schema *Schema, path string, r *Result, resolver Resolver, depth int) { - for _, req := range schema.Required { - if _, ok := obj[req]; !ok { - r.add(join(path, req), "is required") - } - } - - if schema.AdditionalProperties != nil && !*schema.AdditionalProperties && schema.Properties != nil { - keys := make([]string, 0, len(obj)) - for k := range obj { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - if _, declared := schema.Properties[k]; !declared { - // An unknown field is a discoverability signal as much as an - // error: it is usually a consumer guessing at a capability. - r.add(join(path, k), "is not a field of this contract") - } - } - } - - if schema.Properties == nil { - return - } - names := make([]string, 0, len(schema.Properties)) - for name := range schema.Properties { - names = append(names, name) - } - sort.Strings(names) - - for _, name := range names { - if v, ok := obj[name]; ok { - validateValue(v, schema.Properties[name], join(path, name), r, resolver, depth+1) - } - } -} - -func validateArray(arr []any, schema *Schema, path string, r *Result, resolver Resolver, depth int) { - if schema.MinItems != nil && len(arr) < *schema.MinItems { - r.add(path, "needs at least %d items, has %d", *schema.MinItems, len(arr)) - } - if schema.MaxItems != nil && len(arr) > *schema.MaxItems { - r.add(path, "allows at most %d items, has %d", *schema.MaxItems, len(arr)) - } - if schema.Items == nil { - return - } - for i, item := range arr { - validateValue(item, schema.Items, fmt.Sprintf("%s[%d]", path, i), r, resolver, depth+1) - } -} - -func validateString(s string, schema *Schema, path string, r *Result) { - // Length is counted in runes, not bytes. A 4096-character limit that - // rejected a 3000-character entry because of accents would be wrong in - // exactly the case this framework was built to publish. - length := len([]rune(s)) - if schema.MinLength != nil && length < *schema.MinLength { - r.add(path, "needs at least %d characters, has %d", *schema.MinLength, length) - } - if schema.MaxLength != nil && length > *schema.MaxLength { - r.add(path, "allows at most %d characters, has %d", *schema.MaxLength, length) - } -} - -func validateNumber(n float64, schema *Schema, path string, r *Result) { - if schema.Minimum != nil && n < *schema.Minimum { - r.add(path, "must be at least %v", *schema.Minimum) - } - if schema.Maximum != nil && n > *schema.Maximum { - r.add(path, "must be at most %v", *schema.Maximum) - } -} - -// typeAllows reports whether a schema's type declaration includes want. -func typeAllows(schema *Schema, want string) bool { - switch t := schema.Type.(type) { - case string: - return t == want - case []any: - for _, v := range t { - if s, ok := v.(string); ok && s == want { - return true - } - } - } - return false -} - -func matchesType(value any, schema *Schema) bool { - names := typeNames(schema.Type) - if len(names) == 0 { - return true - } - for _, name := range names { - if matchesSingleType(value, name) { - return true - } - } - return false -} - -func matchesSingleType(value any, name string) bool { - switch name { - case "object": - _, ok := value.(map[string]any) - return ok - case "array": - _, ok := value.([]any) - return ok - case "string": - _, ok := value.(string) - return ok - case "boolean": - _, ok := value.(bool) - return ok - case "number": - _, ok := value.(float64) - return ok - case "integer": - // JSON has one number type; an integer is a number with no fraction. - f, ok := value.(float64) - return ok && f == math.Trunc(f) - case "null": - return value == nil - } - return true -} - -func typeNames(t any) []string { - switch v := t.(type) { - case string: - return []string{v} - case []any: - out := make([]string, 0, len(v)) - for _, item := range v { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out - } - return nil -} - -func describeType(t any) string { - names := typeNames(t) - if len(names) == 0 { - return "any" - } - return strings.Join(names, " or ") -} - -func goTypeName(v any) string { - switch t := v.(type) { - case map[string]any: - return "object" - case []any: - return "array" - case string: - return "string" - case bool: - return "boolean" - case float64: - if t == math.Trunc(t) { - return "integer" - } - return "number" - case nil: - return "null" - } - return fmt.Sprintf("%T", v) -} - -func inEnum(value any, enum []any) bool { - for _, candidate := range enum { - if equalJSON(value, candidate) { - return true - } - } - return false -} - -func equalJSON(a, b any) bool { - switch av := a.(type) { - case string: - bv, ok := b.(string) - return ok && av == bv - case float64: - switch bv := b.(type) { - case float64: - return av == bv - case int: - return av == float64(bv) - } - case bool: - bv, ok := b.(bool) - return ok && av == bv - case nil: - return b == nil - } - return fmt.Sprint(a) == fmt.Sprint(b) -} - -func join(path, name string) string { - if path == "" { - return name - } - return path + "." + name -} diff --git a/internal/validate/openapi.go b/internal/validate/openapi.go deleted file mode 100644 index fd07fed..0000000 --- a/internal/validate/openapi.go +++ /dev/null @@ -1,405 +0,0 @@ -package validate - -import ( - "encoding/json" - "fmt" - "net/http" - "sort" - "strings" - - "gopkg.in/yaml.v3" - - "github.com/tegwick/fluid-core/internal/contract" - "github.com/tegwick/fluid-core/internal/runtime" -) - -// Document is the supported subset of an OpenAPI description. -type Document struct { - OpenAPI string `json:"openapi" yaml:"openapi"` - Paths map[string]*PathItem `json:"paths" yaml:"paths"` - Components *Components `json:"components,omitempty" yaml:"components,omitempty"` -} - -// Components holds reusable schemas. -type Components struct { - Schemas map[string]*Schema `json:"schemas,omitempty" yaml:"schemas,omitempty"` -} - -// PathItem holds the operations available at one path. -type PathItem struct { - Get *Operation `json:"get,omitempty" yaml:"get,omitempty"` - Put *Operation `json:"put,omitempty" yaml:"put,omitempty"` - Post *Operation `json:"post,omitempty" yaml:"post,omitempty"` - Delete *Operation `json:"delete,omitempty" yaml:"delete,omitempty"` - Patch *Operation `json:"patch,omitempty" yaml:"patch,omitempty"` - Head *Operation `json:"head,omitempty" yaml:"head,omitempty"` -} - -// operations returns the declared operations by method. -func (p *PathItem) operations() map[string]*Operation { - out := map[string]*Operation{} - for method, op := range map[string]*Operation{ - http.MethodGet: p.Get, http.MethodPut: p.Put, http.MethodPost: p.Post, - http.MethodDelete: p.Delete, http.MethodPatch: p.Patch, http.MethodHead: p.Head, - } { - if op != nil { - out[method] = op - } - } - return out -} - -// Operation is one method on one path. -type Operation struct { - OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` - Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` - RequestBody *RequestBody `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` -} - -// Parameter is a path, query or header parameter. -type Parameter struct { - Name string `json:"name" yaml:"name"` - In string `json:"in" yaml:"in"` - Required bool `json:"required,omitempty" yaml:"required,omitempty"` - Schema *Schema `json:"schema,omitempty" yaml:"schema,omitempty"` -} - -// RequestBody describes an operation's body. -type RequestBody struct { - Required bool `json:"required,omitempty" yaml:"required,omitempty"` - Content map[string]*MediaType `json:"content,omitempty" yaml:"content,omitempty"` -} - -// MediaType binds a content type to a schema. -type MediaType struct { - Schema *Schema `json:"schema,omitempty" yaml:"schema,omitempty"` -} - -// Contract is a parsed, validated OpenAPI description ready to enforce. -type Contract struct { - doc *Document - routes []route -} - -// route is one compiled path template. -type route struct { - template string - segments []segment - item *PathItem -} - -type segment struct { - literal string - variable string -} - -// ParseOpenAPI compiles an OpenAPI document. -// -// Parsing happens once, at revision publication, not per request. Compiling a -// contract on the hot path would put an unbounded amount of work between a -// consumer and their response for no benefit, since the contract cannot change -// without a new revision. -func ParseOpenAPI(raw []byte) (*Contract, error) { - var doc Document - if err := yaml.Unmarshal(raw, &doc); err != nil { - return nil, fmt.Errorf("parse contract: %w", err) - } - if len(doc.Paths) == 0 { - return nil, fmt.Errorf("contract declares no paths") - } - - templates := make([]string, 0, len(doc.Paths)) - for t := range doc.Paths { - templates = append(templates, t) - } - // Sorting makes route order deterministic. Two templates can match the same - // request, and which one wins must not depend on map iteration. - sort.Strings(templates) - - c := &Contract{doc: &doc} - for _, t := range templates { - c.routes = append(c.routes, route{ - template: t, - segments: compile(t), - item: doc.Paths[t], - }) - } - - // Literal routes are matched before templated ones, so /entries/latest - // wins over /entries/{id} regardless of alphabetical order. - sort.SliceStable(c.routes, func(i, j int) bool { - return variableCount(c.routes[i].segments) < variableCount(c.routes[j].segments) - }) - return c, nil -} - -func compile(template string) []segment { - parts := strings.Split(strings.Trim(template, "/"), "/") - out := make([]segment, 0, len(parts)) - for _, p := range parts { - if strings.HasPrefix(p, "{") && strings.HasSuffix(p, "}") { - out = append(out, segment{variable: strings.Trim(p, "{}")}) - continue - } - out = append(out, segment{literal: p}) - } - return out -} - -func variableCount(segs []segment) int { - n := 0 - for _, s := range segs { - if s.variable != "" { - n++ - } - } - return n -} - -// match finds the route serving a path, with its extracted variables. -func (c *Contract) match(path string) (route, map[string]string, bool) { - parts := strings.Split(strings.Trim(path, "/"), "/") - - for _, r := range c.routes { - if len(r.segments) != len(parts) { - continue - } - vars := map[string]string{} - ok := true - for i, seg := range r.segments { - if seg.variable != "" { - if parts[i] == "" { - ok = false - break - } - vars[seg.variable] = parts[i] - continue - } - if seg.literal != parts[i] { - ok = false - break - } - } - if ok { - return r, vars, true - } - } - return route{}, nil, false -} - -// Resolve implements Resolver for local component references. -func (c *Contract) Resolve(ref string) (*Schema, error) { - const prefix = "#/components/schemas/" - if !strings.HasPrefix(ref, prefix) { - // Remote references would make validation depend on a network fetch, - // which the request path must never do. - return nil, fmt.Errorf("%w: only local %s references are supported", ErrUnsupported, prefix) - } - if c.doc.Components == nil { - return nil, fmt.Errorf("contract declares no components") - } - s, ok := c.doc.Components.Schemas[strings.TrimPrefix(ref, prefix)] - if !ok { - return nil, fmt.Errorf("no such component schema") - } - return s, nil -} - -// Operations lists the operations the contract declares, for complexity -// measurement and for reporting surface area. -func (c *Contract) Operations() []string { - var out []string - for _, r := range c.routes { - for method := range r.item.operations() { - out = append(out, method+" "+r.template) - } - } - sort.Strings(out) - return out -} - -// OpenAPIValidator enforces a compiled contract at the gateway. -// -// It implements runtime.ContractValidator. Contracts are compiled per revision -// and cached by contract digest: two revisions sharing a contract share the -// compiled form, and a changed contract is a different digest and so a -// different entry. -type OpenAPIValidator struct { - contracts map[contract.Digest]*Contract -} - -// NewOpenAPIValidator returns a validator holding no contracts. -func NewOpenAPIValidator() *OpenAPIValidator { - return &OpenAPIValidator{contracts: map[contract.Digest]*Contract{}} -} - -// Register compiles and stores the contract for a digest. -func (v *OpenAPIValidator) Register(digest contract.Digest, raw []byte) error { - c, err := ParseOpenAPI(raw) - if err != nil { - return err - } - v.contracts[digest] = c - return nil -} - -// Contract returns a registered contract. -func (v *OpenAPIValidator) Contract(digest contract.Digest) (*Contract, bool) { - c, ok := v.contracts[digest] - return c, ok -} - -// Validate checks a request against its revision's contract. -// -// An unregistered contract is a refusal, not a pass. A gateway that served -// traffic for a revision whose contract it could not find would be serving -// undeclared semantics, which is the thing minimal conformance forbids. -func (v *OpenAPIValidator) Validate(rev contract.Revision, r *http.Request, body []byte) error { - c, ok := v.contracts[rev.Contract.Digest] - if !ok { - return &runtime.ValidationError{ - Message: fmt.Sprintf("no contract registered for revision %s", rev.ID), - } - } - - matched, pathVars, found := c.match(r.URL.Path) - if !found { - return &runtime.ValidationError{ - Message: fmt.Sprintf("%s is not a path in this contract", r.URL.Path), - } - } - - op, ok := matched.item.operations()[r.Method] - if !ok { - allowed := make([]string, 0) - for method := range matched.item.operations() { - allowed = append(allowed, method) - } - sort.Strings(allowed) - return &runtime.ValidationError{ - Message: fmt.Sprintf("%s is not allowed on %s; the contract declares %s", - r.Method, matched.template, strings.Join(allowed, ", ")), - } - } - - result := &Result{} - validateParameters(op, r, pathVars, result, c) - validateBody(op, r, body, result, c) - - if !result.OK() { - return &runtime.ValidationError{ - Field: result.FirstPath(), - Message: result.Error(), - } - } - return nil -} - -func validateParameters(op *Operation, r *http.Request, pathVars map[string]string, result *Result, resolver Resolver) { - query := r.URL.Query() - - for _, p := range op.Parameters { - if p == nil { - continue - } - var ( - raw string - present bool - ) - switch p.In { - case "path": - raw, present = pathVars[p.Name] - case "query": - present = query.Has(p.Name) - raw = query.Get(p.Name) - case "header": - raw = r.Header.Get(p.Name) - present = raw != "" - default: - // Cookie parameters and anything else are reported rather than - // ignored: silently skipping a constraint is how a validator - // reports success it did not earn. - result.add(p.In+"."+p.Name, "%v: parameter location %q", ErrUnsupported, p.In) - continue - } - - if !present { - if p.Required { - result.add(p.In+"."+p.Name, "is required") - } - continue - } - if p.Schema != nil { - validateValue(coerce(raw, p.Schema), p.Schema, p.In+"."+p.Name, result, resolver, 0) - } - } -} - -// coerce turns a string parameter into the type its schema declares. -// -// Query and path parameters arrive as text; comparing them against a numeric -// schema without conversion would fail every well-formed request. -func coerce(raw string, schema *Schema) any { - names := typeNames(schema.Type) - if len(names) == 0 { - return raw - } - switch names[0] { - case "integer", "number": - var f float64 - if _, err := fmt.Sscanf(raw, "%g", &f); err == nil { - return f - } - // Left as a string so the type mismatch is reported honestly rather - // than becoming a confusing zero. - return raw - case "boolean": - switch raw { - case "true": - return true - case "false": - return false - } - return raw - } - return raw -} - -func validateBody(op *Operation, r *http.Request, body []byte, result *Result, resolver Resolver) { - if op.RequestBody == nil { - return - } - if len(body) == 0 { - if op.RequestBody.Required { - result.add("body", "is required") - } - return - } - - mediaType := "application/json" - if ct := r.Header.Get("Content-Type"); ct != "" { - mediaType = strings.TrimSpace(strings.Split(ct, ";")[0]) - } - - media, ok := op.RequestBody.Content[mediaType] - if !ok { - declared := make([]string, 0, len(op.RequestBody.Content)) - for m := range op.RequestBody.Content { - declared = append(declared, m) - } - sort.Strings(declared) - result.add("body", "content type %q is not declared; the contract accepts %s", - mediaType, strings.Join(declared, ", ")) - return - } - if media == nil || media.Schema == nil { - return - } - - var decoded any - if err := json.Unmarshal(body, &decoded); err != nil { - result.add("body", "is not valid JSON: %v", err) - return - } - validateValue(decoded, media.Schema, "body", result, resolver, 0) -} diff --git a/internal/validate/openapi_test.go b/internal/validate/openapi_test.go deleted file mode 100644 index b0b51f0..0000000 --- a/internal/validate/openapi_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package validate - -import ( - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/tegwick/fluid-core/internal/contract" - "github.com/tegwick/fluid-core/internal/runtime" -) - -const hallContract = ` -openapi: "3.1.0" -paths: - /v1/hall-entries: - post: - operationId: publishEntry - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [id, title, body] - additionalProperties: false - properties: - id: {type: string, minLength: 1} - title: {type: string, maxLength: 120} - body: {type: string, maxLength: 4096} - format: {type: string, enum: [teaser, full]} - get: - operationId: listEntries - parameters: - - name: limit - in: query - schema: {type: integer, minimum: 1, maximum: 100} - /v1/hall-entries/latest: - get: - operationId: latestEntry - /v1/hall-entries/{id}: - get: - operationId: getEntry - parameters: - - name: id - in: path - required: true - schema: {type: string, minLength: 1} -` - -func testRevision() contract.Revision { - return contract.Revision{ - ID: "R-1", - Contract: contract.RevisionContract{Digest: contract.Digest("sha256:" + strings.Repeat("1", 64))}, - } -} - -func newValidator(t *testing.T) *OpenAPIValidator { - t.Helper() - v := NewOpenAPIValidator() - if err := v.Register(testRevision().Contract.Digest, []byte(hallContract)); err != nil { - t.Fatal(err) - } - return v -} - -func request(method, target, body string) *http.Request { - var r *http.Request - if body == "" { - r = httptest.NewRequest(method, target, nil) - } else { - r = httptest.NewRequest(method, target, strings.NewReader(body)) - r.Header.Set("Content-Type", "application/json") - } - return r -} - -func TestValidRequestPasses(t *testing.T) { - v := newValidator(t) - body := `{"id":"e-1","title":"the river reached the forge","body":"...","format":"teaser"}` - if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)); err != nil { - t.Fatalf("a conforming request was rejected: %v", err) - } -} - -func TestRequiredFieldsEnforced(t *testing.T) { - v := newValidator(t) - body := `{"id":"e-1"}` - err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)) - if err == nil { - t.Fatal("a request missing required fields was accepted") - } - for _, want := range []string{"title", "body"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("error does not name missing %q: %v", want, err) - } - } -} - -// TestLengthLimitIsCountedInRunes is the case this framework exists to publish: -// a hall entry with accented characters must not be rejected for a byte count -// it never exceeded. -func TestLengthLimitIsCountedInRunes(t *testing.T) { - v := newValidator(t) - - // 3000 multi-byte runes: well under the 4096 rune limit, well over it in bytes. - body := `{"id":"e-1","title":"t","body":"` + strings.Repeat("é", 3000) + `"}` - if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)); err != nil { - t.Fatalf("a 3000-character entry was rejected against a 4096-character limit: %v", err) - } - - over := `{"id":"e-1","title":"t","body":"` + strings.Repeat("a", 4097) + `"}` - if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", over), []byte(over)); err == nil { - t.Error("a 4097-character entry passed a 4096-character limit") - } -} - -func TestUnknownFieldRejectedWhenClosed(t *testing.T) { - v := newValidator(t) - body := `{"id":"e-1","title":"t","body":"b","urgency":"high"}` - err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)) - if err == nil { - t.Fatal("an undeclared field was accepted") - } - if !strings.Contains(err.Error(), "urgency") { - t.Errorf("error does not name the unknown field: %v", err) - } -} - -func TestEnumEnforced(t *testing.T) { - v := newValidator(t) - body := `{"id":"e-1","title":"t","body":"b","format":"interpretive-dance"}` - if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)); err == nil { - t.Error("a value outside the enum was accepted") - } -} - -// TestLiteralRoutesBeatTemplates: /latest must not be swallowed by /{id}. -func TestLiteralRoutesBeatTemplates(t *testing.T) { - c, err := ParseOpenAPI([]byte(hallContract)) - if err != nil { - t.Fatal(err) - } - - matched, _, ok := c.match("/v1/hall-entries/latest") - if !ok { - t.Fatal("no route matched /latest") - } - if matched.template != "/v1/hall-entries/latest" { - t.Errorf("matched %q; the literal route must win over the template", matched.template) - } - - matched, vars, ok := c.match("/v1/hall-entries/e-7") - if !ok { - t.Fatal("no route matched an id") - } - if matched.template != "/v1/hall-entries/{id}" || vars["id"] != "e-7" { - t.Errorf("template match wrong: %q vars %v", matched.template, vars) - } -} - -func TestUnknownPathAndMethodRejected(t *testing.T) { - v := newValidator(t) - - err := v.Validate(testRevision(), request(http.MethodGet, "/v1/nope", ""), nil) - if err == nil { - t.Error("an undeclared path was accepted") - } - - err = v.Validate(testRevision(), request(http.MethodDelete, "/v1/hall-entries", ""), nil) - if err == nil { - t.Fatal("an undeclared method was accepted") - } - // The error should say what is allowed; a bare rejection teaches nothing. - if !strings.Contains(err.Error(), "GET") || !strings.Contains(err.Error(), "POST") { - t.Errorf("error does not name the allowed methods: %v", err) - } -} - -func TestQueryParametersAreCoercedAndBounded(t *testing.T) { - v := newValidator(t) - - if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries?limit=10", ""), nil); err != nil { - t.Errorf("a valid numeric query parameter was rejected: %v", err) - } - if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries?limit=500", ""), nil); err == nil { - t.Error("a query parameter over its maximum was accepted") - } - if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries?limit=lots", ""), nil); err == nil { - t.Error("a non-numeric value for an integer parameter was accepted") - } - // An absent optional parameter is fine. - if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries", ""), nil); err != nil { - t.Errorf("an absent optional parameter was rejected: %v", err) - } -} - -// TestUnregisteredContractIsRefused: serving a revision whose contract cannot -// be found would mean serving undeclared semantics. -func TestUnregisteredContractIsRefused(t *testing.T) { - v := NewOpenAPIValidator() - err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries", ""), nil) - if err == nil { - t.Fatal("a revision with no registered contract was served") - } - var ve *runtime.ValidationError - if !errors.As(err, &ve) { - t.Errorf("got %T, want *runtime.ValidationError", err) - } -} - -func TestMalformedBodyReportedClearly(t *testing.T) { - v := newValidator(t) - body := `{"id": "e-1",` - err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)) - if err == nil { - t.Fatal("malformed JSON was accepted") - } - if !strings.Contains(err.Error(), "valid JSON") { - t.Errorf("error is not clear about the cause: %v", err) - } -} - -func TestRemoteRefsAreRefused(t *testing.T) { - // Resolving a remote reference would make validation depend on a network - // fetch from the request path. - c, err := ParseOpenAPI([]byte(hallContract)) - if err != nil { - t.Fatal(err) - } - if _, err := c.Resolve("https://example.com/schema.json"); !errors.Is(err, ErrUnsupported) { - t.Errorf("a remote $ref was accepted: %v", err) - } -} - -func TestLocalRefsResolve(t *testing.T) { - doc := ` -openapi: "3.1.0" -components: - schemas: - Entry: - type: object - required: [id] - properties: - id: {type: string} -paths: - /v1/entries: - post: - requestBody: - required: true - content: - application/json: - schema: {$ref: "#/components/schemas/Entry"} -` - v := NewOpenAPIValidator() - digest := contract.Digest("sha256:" + strings.Repeat("2", 64)) - if err := v.Register(digest, []byte(doc)); err != nil { - t.Fatal(err) - } - - rev := testRevision() - rev.Contract.Digest = digest - - if err := v.Validate(rev, request(http.MethodPost, "/v1/entries", `{"id":"e-1"}`), []byte(`{"id":"e-1"}`)); err != nil { - t.Errorf("a valid referenced body was rejected: %v", err) - } - if err := v.Validate(rev, request(http.MethodPost, "/v1/entries", `{}`), []byte(`{}`)); err == nil { - t.Error("a body missing a required referenced field was accepted") - } -} - -func TestOperationsListedForComplexityMeasurement(t *testing.T) { - c, err := ParseOpenAPI([]byte(hallContract)) - if err != nil { - t.Fatal(err) - } - ops := c.Operations() - if len(ops) != 4 { - t.Errorf("operations = %v, want 4", ops) - } -} - -func TestEmptyContractRefused(t *testing.T) { - if _, err := ParseOpenAPI([]byte(`openapi: "3.1.0"`)); err == nil { - t.Error("a contract declaring no paths was accepted") - } -} diff --git a/workplans/FLUID-WP-0003-deterministic-data-plane.md b/workplans/FLUID-WP-0003-deterministic-data-plane.md index 2230879..8938a50 100644 --- a/workplans/FLUID-WP-0003-deterministic-data-plane.md +++ b/workplans/FLUID-WP-0003-deterministic-data-plane.md @@ -4,7 +4,7 @@ type: workplan title: "Deterministic data plane (Blueprint Phase A)" domain: infotech repo: fluid-core -status: done +status: active owner: worsch topic_slug: fluid-core created: "2026-09-04" @@ -64,7 +64,7 @@ read from routing policy the router does not author. ```task id: FLUID-WP-0003-T04 -status: done +status: todo priority: high state_hub_task_id: "70bae578-d3e3-5a00-b5cd-4c363bc5b473" ``` diff --git a/workplans/FLUID-WP-0007-conformance-and-self-validation.md b/workplans/FLUID-WP-0007-conformance-and-self-validation.md index c3de5b1..7ab1478 100644 --- a/workplans/FLUID-WP-0007-conformance-and-self-validation.md +++ b/workplans/FLUID-WP-0007-conformance-and-self-validation.md @@ -4,7 +4,7 @@ type: workplan title: "Conformance suite and self-validation" domain: infotech repo: fluid-core -status: done +status: active owner: worsch topic_slug: fluid-core created: "2026-09-04" @@ -25,7 +25,7 @@ here runs in CI with no human steps and no external services. ```task id: FLUID-WP-0007-T01 -status: done +status: todo priority: high state_hub_task_id: "95b8d512-698c-5201-8f8d-18ff0a658f2c" ``` @@ -38,7 +38,7 @@ reproduction of the Blueprint §33 worked example. ```task id: FLUID-WP-0007-T02 -status: done +status: todo priority: high state_hub_task_id: "71724683-78be-568c-bcd4-12df97e53941" ``` @@ -50,7 +50,7 @@ claimed in a README. ```task id: FLUID-WP-0007-T03 -status: done +status: todo priority: high state_hub_task_id: "0ac5c378-038c-5c76-b082-d4868f9bb12b" ``` @@ -63,7 +63,7 @@ until verified) matter most and get dedicated tests. ```task id: FLUID-WP-0007-T04 -status: done +status: todo priority: high state_hub_task_id: "7af0c88f-a00e-5cad-9fb6-2a297ecfd8b3" ``` @@ -76,7 +76,7 @@ configuration each time. ```task id: FLUID-WP-0007-T05 -status: done +status: todo priority: high state_hub_task_id: "45f70116-04f4-5150-9275-2c8023389dc9" ``` @@ -88,7 +88,7 @@ fitness comparison, promotion, complete audit trail. ```task id: FLUID-WP-0007-T06 -status: done +status: todo priority: medium state_hub_task_id: "29ce43f9-17e5-5798-a3f3-051af97224ce" ```