// 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, }, } }