package promotion import ( "context" "errors" "path/filepath" "testing" _ "modernc.org/sqlite" "github.com/tegwick/fluid-core/internal/contract" "github.com/tegwick/fluid-core/internal/evidence" "github.com/tegwick/fluid-core/internal/fitness" "github.com/tegwick/fluid-core/internal/intent" "github.com/tegwick/fluid-core/internal/policy" ) var human = contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"} func newController(t *testing.T) (*Controller, *evidence.SQLStore) { t.Helper() store, err := evidence.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "e.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) return NewController(store, policy.NewGate(policy.DefaultLimits())), store } func passingGateInput() *policy.Input { pc := contract.RevisionPolicyPolicyCheckPassed return &policy.Input{ Descriptor: contract.Revision{ ID: "R-2", Policy: contract.RevisionPolicy{ Compatibility: contract.RevisionPolicyCompatibilityAdditive, SecurityCheck: contract.RevisionPolicySecurityCheckPassed, PolicyCheck: &pc, }, }, GoverningMode: intent.ModeAdvisory, AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation}, ComplexityDelta: 0.2, RequestedTrafficShare: 0.1, Approved: true, ApprovedBy: &human, } } func succeeded() *fitness.Evaluation { return &fitness.Evaluation{Verdict: fitness.VerdictSucceeded, Candidate: "R-2", Control: "R-1"} } func TestPromoteOnGoodEvidence(t *testing.T) { ctx := context.Background() c, store := newController(t) d, err := c.Decide(ctx, Request{ Revision: "R-2", Outcome: Promote, Reason: "requests per task fell from 2.7 to 1.15 with no guardrail breach", Actor: human, Experiment: "E-1", Hypotheses: []contract.HypothesisID{"H-1"}, Evaluation: succeeded(), GateInput: passingGateInput(), }) if err != nil { t.Fatalf("a well-evidenced promotion was refused: %v", err) } if !d.GateAllowed || d.Override { t.Errorf("decision = %+v", d) } // The decision must be traceable back to the evidence it rests on. if d.Experiment != "E-1" || len(d.Hypotheses) != 1 { t.Error("decision does not cite its evidence") } events, err := store.Events(ctx, evidence.EventFilter{EntityID: "R-2"}) if err != nil { t.Fatal(err) } if len(events) != 1 || events[0].EventType != "PROMOTION_DECIDED_PROMOTE" { t.Errorf("promotion left %d events: %+v", len(events), events) } } // TestGateRefusalCannotBeOverridden: a human may act against the evidence, but // never against the deterministic gate. func TestGateRefusalCannotBeOverridden(t *testing.T) { ctx := context.Background() c, store := newController(t) gate := passingGateInput() gate.Descriptor.Policy.Compatibility = contract.RevisionPolicyCompatibilityBreaking _, err := c.Decide(ctx, Request{ Revision: "R-2", Outcome: Promote, Reason: "I am confident", Actor: human, Evaluation: succeeded(), GateInput: gate, AcknowledgeOverride: true, // must not help }) if !errors.Is(err, ErrGateRefused) { t.Fatalf("the gate was overridden: %v", err) } // The blocked attempt is itself worth auditing. events, qerr := store.Events(ctx, evidence.EventFilter{EntityID: "R-2"}) if qerr != nil { t.Fatal(qerr) } if len(events) != 1 || events[0].EventType != "PROMOTION_REFUSED_PROMOTE" { t.Errorf("a refused promotion left no audit trail: %+v", events) } } // TestPromotingAgainstEvidenceRequiresAcknowledgement keeps a judgement call // visible as one instead of letting it read like a normal promotion. func TestPromotingAgainstEvidenceRequiresAcknowledgement(t *testing.T) { ctx := context.Background() c, _ := newController(t) inconclusive := &fitness.Evaluation{ Verdict: fitness.VerdictInconclusive, Reasons: []string{"only 11 samples per arm"}, } _, err := c.Decide(ctx, Request{ Revision: "R-2", Outcome: Promote, Reason: "we need this before the launch", Actor: human, Evaluation: inconclusive, GateInput: passingGateInput(), }) if err == nil { t.Fatal("an inconclusive candidate was promoted without acknowledgement") } d, err := c.Decide(ctx, Request{ Revision: "R-2", Outcome: Promote, Reason: "we need this before the launch", Actor: human, Evaluation: inconclusive, GateInput: passingGateInput(), AcknowledgeOverride: true, }) if err != nil { t.Fatalf("an acknowledged override was refused: %v", err) } if !d.Override { t.Error("the override was not recorded on the decision") } if d.FitnessVerdict != fitness.VerdictInconclusive { t.Error("the verdict being overridden was not recorded") } } // TestReducingExposureNeedsNoGate: requiring permission to stop would point a // safety property the wrong way. func TestReducingExposureNeedsNoGate(t *testing.T) { ctx := context.Background() c, _ := newController(t) for _, outcome := range []Outcome{Revert, Abandon, Defer, RetainAsOption, Modify} { if _, err := c.Decide(ctx, Request{ Revision: "R-2", Outcome: outcome, Reason: "rolling back after a latency regression", Actor: human, // No gate input and no evaluation: stopping must always be possible. }); err != nil { t.Errorf("%s was refused: %v", outcome, err) } } } func TestPromotionNeedsEvidenceAndAttribution(t *testing.T) { ctx := context.Background() c, _ := newController(t) if _, err := c.Decide(ctx, Request{ Revision: "R-2", Outcome: Promote, Reason: "because", Actor: human, GateInput: passingGateInput(), }); !errors.Is(err, ErrNoEvidence) { t.Errorf("promotion with no fitness verdict returned %v", err) } if _, err := c.Decide(ctx, Request{ Revision: "R-2", Outcome: Promote, Reason: "because", Evaluation: succeeded(), GateInput: passingGateInput(), }); err == nil { t.Error("an unattributed decision was accepted") } if _, err := c.Decide(ctx, Request{ Revision: "R-2", Outcome: Promote, Actor: human, Evaluation: succeeded(), GateInput: passingGateInput(), }); err == nil { t.Error("a decision with no reason was accepted") } } func TestDecisionHistoryIsOrdered(t *testing.T) { ctx := context.Background() c, _ := newController(t) for _, outcome := range []Outcome{ExpandExperiment, Revert} { req := Request{ Revision: "R-2", Outcome: outcome, Reason: "step", Actor: human, } if wideningExposure(outcome) { req.Evaluation = succeeded() req.GateInput = passingGateInput() } if _, err := c.Decide(ctx, req); err != nil { t.Fatalf("%s: %v", outcome, err) } } history, err := c.ForRevision(ctx, "R-2") if err != nil { t.Fatal(err) } if len(history) != 2 { t.Fatalf("history has %d entries, want 2", len(history)) } if history[0].DecidedAt.After(history[1].DecidedAt) { t.Error("decision history is not in chronological order") } } func TestUnknownOutcomeRefused(t *testing.T) { ctx := context.Background() c, _ := newController(t) if _, err := c.Decide(ctx, Request{ Revision: "R-2", Outcome: Outcome("SHIP_IT"), Reason: "r", Actor: human, }); err == nil { t.Error("an undefined outcome was accepted") } }