225 lines
8 KiB
Go
225 lines
8 KiB
Go
|
|
// 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,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|