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
This commit is contained in:
parent
d52dcc92a9
commit
a2d561eae5
9 changed files with 1679 additions and 1 deletions
224
internal/policy/gate.go
Normal file
224
internal/policy/gate.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// Package policy implements the deterministic policy gate.
|
||||
//
|
||||
// ArchitectureBlueprint.md section 28.2: every candidate promotion must pass
|
||||
// deterministic gates, and this gate is "the architectural boundary preventing
|
||||
// agentic reasoning from becoming security policy".
|
||||
//
|
||||
// Nothing in this package may consult a model, call out to a service, or take
|
||||
// an opinion as input. A gate that can be argued with is not a gate. The Daimon
|
||||
// may reason about policy (section 48.7) but must not be the implementation of
|
||||
// it, so every decision here is a pure function of the candidate, the governing
|
||||
// intent, and explicitly configured limits.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/intent"
|
||||
)
|
||||
|
||||
// Limits are the deterministic constraints a candidate must satisfy.
|
||||
//
|
||||
// They come from the interface evolution intent and from operator
|
||||
// configuration, never from the candidate itself. A candidate that could raise
|
||||
// its own ceiling would make the gate decorative.
|
||||
type Limits struct {
|
||||
// AllowedAdaptationClasses restricts what kinds of change may be promoted.
|
||||
// Empty means every class is allowed.
|
||||
AllowedAdaptationClasses []contract.AdaptationClass
|
||||
|
||||
// RequiredMode is the minimum authority the governing intent must declare
|
||||
// for this promotion to be permitted at all.
|
||||
RequiredMode intent.AuthorityMode
|
||||
|
||||
// MaxComplexityDelta caps the complexity a single candidate may add.
|
||||
// Complexity is a budget (FluidAPIStandards.md section 22); a candidate may
|
||||
// be rejected even when it increases local utility.
|
||||
MaxComplexityDelta float64
|
||||
// ComplexityLimitSet distinguishes "no limit" from "limit of zero".
|
||||
ComplexityLimitSet bool
|
||||
|
||||
// MaxTrafficShare caps the exposure any single non-stable revision may take.
|
||||
MaxTrafficShare float64
|
||||
|
||||
// RequireApproval demands a recorded human or policy authorization.
|
||||
RequireApproval bool
|
||||
|
||||
// ProhibitedCompatibility lists compatibility classes that may never be
|
||||
// promoted automatically, whatever else passes.
|
||||
ProhibitedCompatibility []contract.RevisionPolicyCompatibility
|
||||
}
|
||||
|
||||
// Decision is the gate's verdict.
|
||||
type Decision struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
// Input is everything the gate is permitted to consider.
|
||||
type Input struct {
|
||||
Descriptor contract.Revision
|
||||
// GoverningMode is the authority mode declared by the intent that governs
|
||||
// this revision.
|
||||
GoverningMode intent.AuthorityMode
|
||||
// AdaptationClasses describes what the candidate changes.
|
||||
AdaptationClasses []contract.AdaptationClass
|
||||
// ComplexityDelta is the measured complexity impact.
|
||||
ComplexityDelta float64
|
||||
// RequestedTrafficShare is the exposure being asked for.
|
||||
RequestedTrafficShare float64
|
||||
// Approved reports whether a recorded authorization exists.
|
||||
Approved bool
|
||||
// ApprovedBy identifies the authorizing actor, when there is one.
|
||||
ApprovedBy *contract.Actor
|
||||
}
|
||||
|
||||
// Gate evaluates candidates against fixed limits.
|
||||
type Gate struct{ limits Limits }
|
||||
|
||||
// NewGate returns a gate enforcing the given limits.
|
||||
func NewGate(l Limits) *Gate { return &Gate{limits: l} }
|
||||
|
||||
// Limits returns the configured limits, for display and audit.
|
||||
func (g *Gate) Limits() Limits { return g.limits }
|
||||
|
||||
// Evaluate applies every gate and returns a single decision.
|
||||
//
|
||||
// All checks run even after the first failure. A caller fixing one rejection
|
||||
// only to hit the next is a worse experience than being told everything at
|
||||
// once, and the full list is better evidence for the audit trail.
|
||||
func (g *Gate) Evaluate(in Input) Decision {
|
||||
var reasons []string
|
||||
|
||||
// Security status is checked first because it is the one condition where a
|
||||
// pass by any other measure is irrelevant.
|
||||
if in.Descriptor.Policy.SecurityCheck != contract.RevisionPolicySecurityCheckPassed {
|
||||
reasons = append(reasons, fmt.Sprintf(
|
||||
"security check is %q, must be %q",
|
||||
in.Descriptor.Policy.SecurityCheck, contract.RevisionPolicySecurityCheckPassed))
|
||||
}
|
||||
|
||||
if pc := in.Descriptor.Policy.PolicyCheck; pc != nil && *pc == contract.RevisionPolicyPolicyCheckFailed {
|
||||
reasons = append(reasons, "policy check failed")
|
||||
}
|
||||
|
||||
if !in.GoverningMode.Valid() {
|
||||
reasons = append(reasons, "governing intent declares no valid authority mode")
|
||||
} else if !in.GoverningMode.Allows(g.limits.RequiredMode) {
|
||||
reasons = append(reasons, fmt.Sprintf(
|
||||
"governing intent is at %s, but this promotion requires at least %s",
|
||||
in.GoverningMode, g.limits.RequiredMode))
|
||||
}
|
||||
|
||||
if len(g.limits.AllowedAdaptationClasses) > 0 {
|
||||
allowed := map[contract.AdaptationClass]bool{}
|
||||
for _, c := range g.limits.AllowedAdaptationClasses {
|
||||
allowed[c] = true
|
||||
}
|
||||
for _, c := range in.AdaptationClasses {
|
||||
if !allowed[c] {
|
||||
reasons = append(reasons, fmt.Sprintf(
|
||||
"adaptation class %q is not permitted here (permitted: %s)",
|
||||
c, formatClasses(g.limits.AllowedAdaptationClasses)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, prohibited := range g.limits.ProhibitedCompatibility {
|
||||
if in.Descriptor.Policy.Compatibility == prohibited {
|
||||
reasons = append(reasons, fmt.Sprintf(
|
||||
"compatibility class %q may not be promoted under this policy", prohibited))
|
||||
}
|
||||
}
|
||||
|
||||
if g.limits.ComplexityLimitSet && in.ComplexityDelta > g.limits.MaxComplexityDelta {
|
||||
reasons = append(reasons, fmt.Sprintf(
|
||||
"complexity delta %.3f exceeds the budget of %.3f",
|
||||
in.ComplexityDelta, g.limits.MaxComplexityDelta))
|
||||
}
|
||||
|
||||
// Two ceilings apply to exposure, and the tighter one wins: the descriptor
|
||||
// may declare its own maximum, and the gate imposes one. Taking the minimum
|
||||
// means a descriptor can restrict itself further but never widen.
|
||||
ceiling := g.limits.MaxTrafficShare
|
||||
if r := in.Descriptor.Routing; r != nil && r.MaxTrafficShare != nil {
|
||||
if declared := float64(*r.MaxTrafficShare); declared < ceiling || ceiling == 0 {
|
||||
ceiling = declared
|
||||
}
|
||||
}
|
||||
if ceiling > 0 && in.RequestedTrafficShare > ceiling {
|
||||
reasons = append(reasons, fmt.Sprintf(
|
||||
"requested traffic share %.2f exceeds the ceiling of %.2f",
|
||||
in.RequestedTrafficShare, ceiling))
|
||||
}
|
||||
|
||||
if g.limits.RequireApproval && !in.Approved {
|
||||
reasons = append(reasons, "this promotion requires a recorded authorization and has none")
|
||||
}
|
||||
if in.Approved && in.ApprovedBy == nil {
|
||||
reasons = append(reasons, "promotion is marked approved but names no authorizing actor")
|
||||
}
|
||||
|
||||
// Generation authority is not promotion authority (Blueprint section 28.1).
|
||||
// A candidate that a Daimon both produced and approved has had no
|
||||
// independent check at all.
|
||||
if in.Approved && in.ApprovedBy != nil && in.ApprovedBy.Type == contract.ActorTypeDaimon {
|
||||
if !g.limits.RequiredMode.Allows(intent.ModeBoundedAutonomous) ||
|
||||
!in.GoverningMode.Allows(intent.ModeBoundedAutonomous) {
|
||||
reasons = append(reasons, fmt.Sprintf(
|
||||
"authorization by a daimon requires at least %s authority, but the intent is at %s",
|
||||
intent.ModeBoundedAutonomous, in.GoverningMode))
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(reasons)
|
||||
return Decision{Allowed: len(reasons) == 0, Reasons: reasons}
|
||||
}
|
||||
|
||||
func formatClasses(cs []contract.AdaptationClass) string {
|
||||
out := make([]string, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = string(c)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return joinComma(out)
|
||||
}
|
||||
|
||||
func joinComma(items []string) string {
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "none"
|
||||
case 1:
|
||||
return items[0]
|
||||
}
|
||||
s := items[0]
|
||||
for _, item := range items[1:] {
|
||||
s += ", " + item
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// DefaultLimits returns a conservative starting configuration.
|
||||
//
|
||||
// The defaults refuse breaking changes, require approval, and permit only
|
||||
// presentation and implementation adaptations — the two classes Blueprint
|
||||
// section 37 identifies as safest. An interface that needs more should say so
|
||||
// explicitly in its intent rather than inherit it.
|
||||
func DefaultLimits() Limits {
|
||||
return Limits{
|
||||
AllowedAdaptationClasses: []contract.AdaptationClass{
|
||||
contract.AdaptationClassPresentation,
|
||||
contract.AdaptationClassImplementation,
|
||||
},
|
||||
RequiredMode: intent.ModeAdvisory,
|
||||
MaxComplexityDelta: 1.0,
|
||||
ComplexityLimitSet: true,
|
||||
MaxTrafficShare: 0.25,
|
||||
RequireApproval: true,
|
||||
ProhibitedCompatibility: []contract.RevisionPolicyCompatibility{
|
||||
contract.RevisionPolicyCompatibilityBreaking,
|
||||
},
|
||||
}
|
||||
}
|
||||
213
internal/policy/gate_test.go
Normal file
213
internal/policy/gate_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue