diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf956f5..be00303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,8 @@ 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 5d445bb..ebca366 100644 --- a/Makefile +++ b/Makefile @@ -41,8 +41,8 @@ check-generated: ## Fail if generated code is stale relative to schemas/ fi .PHONY: conformance -conformance: ## Full conformance suite (grows through FLUID-WP-0007) - $(GO) test ./conformance/... $(PKGS) +conformance: ## Assert every conformance requirement and architectural invariant + $(GO) test -count=1 ./conformance/... .PHONY: clean clean: diff --git a/conformance/suite/conformance_test.go b/conformance/suite/conformance_test.go new file mode 100644 index 0000000..f3da702 --- /dev/null +++ b/conformance/suite/conformance_test.go @@ -0,0 +1,448 @@ +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 new file mode 100644 index 0000000..e56dc30 --- /dev/null +++ b/conformance/suite/containment_test.go @@ -0,0 +1,236 @@ +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 new file mode 100644 index 0000000..b869797 --- /dev/null +++ b/conformance/suite/harness.go @@ -0,0 +1,310 @@ +// 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 new file mode 100644 index 0000000..3aa9dd0 --- /dev/null +++ b/conformance/suite/loop.go @@ -0,0 +1,321 @@ +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 new file mode 100644 index 0000000..2b1c6c0 --- /dev/null +++ b/conformance/suite/repo.go @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..8de9816 --- /dev/null +++ b/conformance/suite/slice_test.go @@ -0,0 +1,222 @@ +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 new file mode 100644 index 0000000..ecd7b8e --- /dev/null +++ b/docs/integration-guide.md @@ -0,0 +1,137 @@ +# 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 new file mode 100644 index 0000000..f5bb095 --- /dev/null +++ b/examples/echo-interface/README.md @@ -0,0 +1,21 @@ +# 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 new file mode 100644 index 0000000..ea7c5c9 --- /dev/null +++ b/examples/echo-interface/adapter.go @@ -0,0 +1,67 @@ +// 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 new file mode 100644 index 0000000..19468d8 --- /dev/null +++ b/examples/echo-interface/r1.openapi.yaml @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..254ea24 --- /dev/null +++ b/examples/echo-interface/r2.openapi.yaml @@ -0,0 +1,15 @@ +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 974de8f..ca0ede5 100644 --- a/internal/runtime/telemetry.go +++ b/internal/runtime/telemetry.go @@ -29,6 +29,7 @@ type Emitter struct { dropped atomic.Int64 written atomic.Int64 failed atomic.Int64 + inflight atomic.Int64 stopOnce sync.Once done chan struct{} wg sync.WaitGroup @@ -76,6 +77,7 @@ 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) } @@ -89,25 +91,13 @@ func (e *Emitter) run(timeout time.Duration) { if !ok { return } - 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.deliver(ev, timeout) case <-e.done: // Drain what is already buffered, then stop. for { select { case ev := <-e.ch: - 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.deliver(ev, timeout) default: return } @@ -116,6 +106,39 @@ 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 fdb548f..6c0300c 100644 --- a/internal/signing/signing.go +++ b/internal/signing/signing.go @@ -9,12 +9,16 @@ 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. @@ -195,6 +199,12 @@ 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 new file mode 100644 index 0000000..3200b26 --- /dev/null +++ b/internal/validate/jsonschema.go @@ -0,0 +1,393 @@ +// 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 new file mode 100644 index 0000000..fd07fed --- /dev/null +++ b/internal/validate/openapi.go @@ -0,0 +1,405 @@ +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 new file mode 100644 index 0000000..b0b51f0 --- /dev/null +++ b/internal/validate/openapi_test.go @@ -0,0 +1,288 @@ +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 8938a50..2230879 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: active +status: done 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: todo +status: done 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 7ab1478..c3de5b1 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: active +status: done 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: todo +status: done 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: todo +status: done 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: todo +status: done 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: todo +status: done 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: todo +status: done 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: todo +status: done priority: medium state_hub_task_id: "29ce43f9-17e5-5798-a3f3-051af97224ce" ```