fluid-core/internal/policy/gate_test.go
tegwick a2d561eae5 Add signing, trust types, policy gate and publication pipeline
FLUID-WP-0004 T01, T02, T05, T06. The pipeline is the only path from
Candidate to Verified: no other code constructs a Verified value, and
Publish takes one, so "AI-generated artifacts are untrusted until
verified" is a property of the type signatures rather than a rule people
are asked to remember.

The policy gate is a pure function of the candidate, the governing
intent and configured limits. It cannot consult a model or take an
opinion as input, because a gate that can be argued with is not a gate.
Two behaviours it enforces are worth naming: the tighter of the
descriptor's own traffic ceiling and the gate's wins, so a descriptor
can restrict itself but never widen; and a daimon cannot authorize its
own promotion below FLUID-5, since generation authority is not promotion
authority.

Signatures cover the canonical document with the signature member
removed, so a signed descriptor round-trips and a tampered one does not.
The registry now refuses anything that does not verify, which is what
makes the pipeline's signature mean something at the router.

An unchecked pipeline stage is recorded as unchecked rather than
omitted, so a pipeline with no security check cannot look identical to
one that passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:56:30 +02:00

213 lines
6.6 KiB
Go

package policy
import (
"strings"
"testing"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/intent"
)
func passing() contract.Revision {
pc := contract.RevisionPolicyPolicyCheckPassed
return contract.Revision{
ID: "R-2",
Interface: "hall-publishing",
State: contract.RevisionStateCandidate,
Policy: contract.RevisionPolicy{
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
PolicyCheck: &pc,
},
}
}
func human() *contract.Actor {
return &contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}
}
func baseInput() Input {
return Input{
Descriptor: passing(),
GoverningMode: intent.ModeAdvisory,
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
ComplexityDelta: 0.2,
RequestedTrafficShare: 0.10,
Approved: true,
ApprovedBy: human(),
}
}
func TestGateAllowsAConformingCandidate(t *testing.T) {
d := NewGate(DefaultLimits()).Evaluate(baseInput())
if !d.Allowed {
t.Fatalf("conforming candidate rejected: %v", d.Reasons)
}
}
func TestGateRefusesUnverifiedSecurity(t *testing.T) {
in := baseInput()
in.Descriptor.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckPending
d := NewGate(DefaultLimits()).Evaluate(in)
if d.Allowed {
t.Fatal("a candidate with a pending security check was allowed")
}
if !mentions(d.Reasons, "security check") {
t.Errorf("reasons do not name the security check: %v", d.Reasons)
}
}
func TestGateRefusesBreakingChange(t *testing.T) {
in := baseInput()
in.Descriptor.Policy.Compatibility = contract.RevisionPolicyCompatibilityBreaking
if d := NewGate(DefaultLimits()).Evaluate(in); d.Allowed {
t.Fatal("a breaking change passed the default gate")
}
}
func TestGateEnforcesAuthorityMode(t *testing.T) {
limits := DefaultLimits()
limits.RequiredMode = intent.ModeExperimental
in := baseInput()
in.GoverningMode = intent.ModeAdvisory
d := NewGate(limits).Evaluate(in)
if d.Allowed {
t.Fatal("promotion requiring FLUID-4 was allowed under a FLUID-2 intent")
}
if !mentions(d.Reasons, "FLUID-2") {
t.Errorf("reasons do not name the governing mode: %v", d.Reasons)
}
in.GoverningMode = intent.ModeEvolutionary
if d := NewGate(limits).Evaluate(in); !d.Allowed {
t.Errorf("FLUID-6 should satisfy a FLUID-4 requirement: %v", d.Reasons)
}
}
func TestGateEnforcesComplexityBudget(t *testing.T) {
// FluidAPIStandards.md section 23: a candidate may be rejected even when it
// increases local utility.
in := baseInput()
in.ComplexityDelta = 9.0
d := NewGate(DefaultLimits()).Evaluate(in)
if d.Allowed {
t.Fatal("a candidate far over the complexity budget was allowed")
}
if !mentions(d.Reasons, "complexity") {
t.Errorf("reasons do not name complexity: %v", d.Reasons)
}
}
func TestGateTakesTheTighterTrafficCeiling(t *testing.T) {
limits := DefaultLimits()
limits.MaxTrafficShare = 0.50
in := baseInput()
share := contract.UnitInterval(0.10)
in.Descriptor.Routing = &contract.RevisionRouting{MaxTrafficShare: &share}
in.RequestedTrafficShare = 0.30
// The descriptor restricts itself below the gate's ceiling; the tighter of
// the two must win, or a descriptor's self-restriction would be advisory.
if d := NewGate(limits).Evaluate(in); d.Allowed {
t.Fatal("requested share exceeded the descriptor's own ceiling but was allowed")
}
// A descriptor must not be able to widen past the gate.
wide := contract.UnitInterval(0.99)
in.Descriptor.Routing = &contract.RevisionRouting{MaxTrafficShare: &wide}
in.RequestedTrafficShare = 0.80
if d := NewGate(limits).Evaluate(in); d.Allowed {
t.Fatal("a descriptor widened its own exposure past the gate ceiling")
}
}
func TestGateRequiresApproval(t *testing.T) {
in := baseInput()
in.Approved = false
in.ApprovedBy = nil
if d := NewGate(DefaultLimits()).Evaluate(in); d.Allowed {
t.Fatal("an unapproved candidate passed a gate that requires approval")
}
}
// TestDaimonCannotSelfApproveBelowBoundedAutonomy defends Blueprint 28.1:
// generation authority is not promotion authority. A candidate a Daimon both
// produced and approved has had no independent check.
func TestDaimonCannotSelfApproveBelowBoundedAutonomy(t *testing.T) {
in := baseInput()
in.ApprovedBy = &contract.Actor{Type: contract.ActorTypeDaimon, ID: "fluid-daimon/hall"}
d := NewGate(DefaultLimits()).Evaluate(in)
if d.Allowed {
t.Fatal("a daimon authorized its own promotion at FLUID-2")
}
if !mentions(d.Reasons, "daimon") {
t.Errorf("reasons do not name the daimon authorization: %v", d.Reasons)
}
// At bounded autonomy, with the policy demanding it, this is legitimate.
limits := DefaultLimits()
limits.RequiredMode = intent.ModeBoundedAutonomous
in.GoverningMode = intent.ModeBoundedAutonomous
if d := NewGate(limits).Evaluate(in); !d.Allowed {
t.Errorf("daimon authorization refused at FLUID-5: %v", d.Reasons)
}
}
func TestGateReportsEveryFailure(t *testing.T) {
// A caller fixing one rejection only to hit the next learns less than one
// told everything at once, and the audit trail wants the whole list.
in := baseInput()
in.Descriptor.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckFailed
in.Descriptor.Policy.Compatibility = contract.RevisionPolicyCompatibilityBreaking
in.ComplexityDelta = 50
in.Approved = false
in.ApprovedBy = nil
in.AdaptationClasses = []contract.AdaptationClass{contract.AdaptationClassContract}
d := NewGate(DefaultLimits()).Evaluate(in)
if d.Allowed {
t.Fatal("a candidate failing five gates was allowed")
}
if len(d.Reasons) < 5 {
t.Errorf("expected at least five reasons, got %d: %v", len(d.Reasons), d.Reasons)
}
}
func TestGateIsDeterministic(t *testing.T) {
// The gate must be a pure function: same input, same verdict, same reasons
// in the same order. A gate whose output varies cannot be audited.
in := baseInput()
in.Descriptor.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckFailed
in.AdaptationClasses = []contract.AdaptationClass{contract.AdaptationClassContract}
g := NewGate(DefaultLimits())
first := g.Evaluate(in)
for i := 0; i < 100; i++ {
again := g.Evaluate(in)
if again.Allowed != first.Allowed || len(again.Reasons) != len(first.Reasons) {
t.Fatalf("verdict varied between runs")
}
for j := range first.Reasons {
if again.Reasons[j] != first.Reasons[j] {
t.Fatalf("reason order varied: %q vs %q", first.Reasons[j], again.Reasons[j])
}
}
}
}
func mentions(reasons []string, substr string) bool {
for _, r := range reasons {
if strings.Contains(strings.ToLower(r), strings.ToLower(substr)) {
return true
}
}
return false
}