// Package agentpolicy implements the deterministic target policy evaluator for // the IOP agent CLI runtime. It consumes an immutable agentconfig runtime // snapshot and a runtime selection context to produce a single, durable // RouteDecision. package agentpolicy import ( "bytes" "crypto/sha256" "crypto/subtle" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "io" "time" ) const ( decisionVersion = "1" CandidateSourceRule = "rule" CandidateSourceDefault = "default" CandidateReasonMatched = "matched" CandidateReasonDefault = "default" CandidateReasonNotEvaluatedAfterFirstHit = "not evaluated after first match" ) // RouteDecision is the durable, single-target result of evaluating an ordered // selection policy. Candidate evidence preserves the complete ordered policy // traversal, and History records every route that was actually used. type RouteDecision struct { ProviderID string `json:"provider_id"` ModelID string `json:"model_id"` ProfileID string `json:"profile_id"` ProfileRevision string `json:"profile_revision"` ConfigRevision string `json:"config_revision"` SelectionRevision string `json:"selection_revision"` SelectedRuleID string `json:"selected_rule_id"` SelectedReason string `json:"selected_reason"` Candidates []CandidateEvaluation `json:"candidates"` History RouteHistory `json:"history"` } // CandidateEvaluation records one ordered rule or default candidate. Rules // after the first match remain explicit but unevaluated. type CandidateEvaluation struct { Source string `json:"source"` RuleID string `json:"rule_id"` ProviderID string `json:"provider_id"` ModelID string `json:"model_id"` ProfileID string `json:"profile_id"` ProfileRevision string `json:"profile_revision"` Evaluated bool `json:"evaluated"` Eligible bool `json:"eligible"` Used bool `json:"used"` Reason string `json:"reason"` } // RouteHistory is the immutable, revision-pinned record of a persisted route. type RouteHistory struct { DecisionID string `json:"decision_id"` SelectedAt time.Time `json:"selected_at"` UsedRoutes []UsedRoute `json:"used_routes"` } // UsedRoute records one route that was actually selected for execution. type UsedRoute struct { ProviderID string `json:"provider_id"` ModelID string `json:"model_id"` ProfileID string `json:"profile_id"` ProfileRevision string `json:"profile_revision"` RuleID string `json:"rule_id"` Reason string `json:"reason"` UsedAt time.Time `json:"used_at"` } // DecisionExpectation pins both configuration and effective selection policy // revisions when a durable decision is decoded or resumed. type DecisionExpectation struct { ConfigRevision string SelectionRevision string } // decisionEnvelope is a strict versioned wrapper. Integrity covers // length-prefixed version, revisions, and canonical decision bytes. type decisionEnvelope struct { Version string `json:"version"` ConfigRevision string `json:"config_revision"` SelectionRevision string `json:"selection_revision"` Decision json.RawMessage `json:"decision"` Integrity string `json:"integrity"` } // EncodeDecision serialises and integrity-seals one structurally valid // RouteDecision. func EncodeDecision(decision RouteDecision) ([]byte, error) { if err := validateDecision(decision); err != nil { return nil, err } decisionData, err := json.Marshal(decision) if err != nil { return nil, fmt.Errorf("agentpolicy: encode decision payload: %w", err) } envelope := decisionEnvelope{ Version: decisionVersion, ConfigRevision: decision.ConfigRevision, SelectionRevision: decision.SelectionRevision, Decision: decisionData, Integrity: decisionIntegrity( decisionVersion, decision.ConfigRevision, decision.SelectionRevision, decisionData, ), } data, err := json.Marshal(envelope) if err != nil { return nil, fmt.Errorf("agentpolicy: encode decision envelope: %w", err) } return data, nil } // DecodeDecision strictly decodes, integrity-checks, and revision-pins one // durable decision. It never returns an unchecked payload. func DecodeDecision(data []byte, expected DecisionExpectation) (RouteDecision, error) { if expected.ConfigRevision == "" || expected.SelectionRevision == "" { return RouteDecision{}, fmt.Errorf( "%w: both expected config and selection revisions are required", ErrRevisionMismatch, ) } var envelope decisionEnvelope if err := decodeStrictJSON(data, &envelope); err != nil { return RouteDecision{}, fmt.Errorf("%w: envelope: %v", ErrCorruptDecision, err) } if envelope.Version != decisionVersion { return RouteDecision{}, fmt.Errorf( "%w: unsupported version %q", ErrCorruptDecision, envelope.Version, ) } if envelope.ConfigRevision == "" || envelope.SelectionRevision == "" || len(envelope.Decision) == 0 || envelope.Integrity == "" { return RouteDecision{}, fmt.Errorf("%w: incomplete envelope", ErrCorruptDecision) } wantIntegrity := decisionIntegrity( envelope.Version, envelope.ConfigRevision, envelope.SelectionRevision, envelope.Decision, ) if !constantBytesEqual(envelope.Integrity, wantIntegrity) { return RouteDecision{}, fmt.Errorf("%w: integrity mismatch", ErrCorruptDecision) } var decision RouteDecision if err := decodeStrictJSON(envelope.Decision, &decision); err != nil { return RouteDecision{}, fmt.Errorf("%w: decision: %v", ErrCorruptDecision, err) } canonicalDecision, err := json.Marshal(decision) if err != nil { return RouteDecision{}, fmt.Errorf("%w: canonical decision: %v", ErrCorruptDecision, err) } if !bytes.Equal(envelope.Decision, canonicalDecision) { return RouteDecision{}, fmt.Errorf("%w: decision payload is not canonical", ErrCorruptDecision) } if envelope.ConfigRevision != decision.ConfigRevision || envelope.SelectionRevision != decision.SelectionRevision { return RouteDecision{}, fmt.Errorf( "%w: envelope and decision revisions disagree", ErrCorruptDecision, ) } if err := validateDecision(decision); err != nil { return RouteDecision{}, err } if envelope.ConfigRevision != expected.ConfigRevision || envelope.SelectionRevision != expected.SelectionRevision { return RouteDecision{}, fmt.Errorf( "%w: got config=%q selection=%q, want config=%q selection=%q", ErrRevisionMismatch, envelope.ConfigRevision, envelope.SelectionRevision, expected.ConfigRevision, expected.SelectionRevision, ) } return decision, nil } func validateDecision(decision RouteDecision) error { if decision.ProviderID == "" || decision.ModelID == "" || decision.ProfileID == "" || decision.ProfileRevision == "" { return fmt.Errorf("%w: selected target identity is incomplete", ErrCorruptDecision) } if decision.ConfigRevision == "" || decision.SelectionRevision == "" { return fmt.Errorf("%w: decision revisions are incomplete", ErrCorruptDecision) } if decision.SelectedReason == "" || len(decision.Candidates) == 0 { return fmt.Errorf("%w: selection evidence is incomplete", ErrCorruptDecision) } usedIndex := -1 defaultIndex := -1 for index, candidate := range decision.Candidates { if candidate.Source != CandidateSourceRule && candidate.Source != CandidateSourceDefault { return fmt.Errorf( "%w: candidate %d has unknown source %q", ErrCorruptDecision, index, candidate.Source, ) } if candidate.Source == CandidateSourceRule && candidate.RuleID == "" { return fmt.Errorf("%w: candidate %d rule id is empty", ErrCorruptDecision, index) } if candidate.Source == CandidateSourceDefault { if candidate.RuleID != "" || defaultIndex >= 0 || index != len(decision.Candidates)-1 { return fmt.Errorf("%w: default candidate ordering is invalid", ErrCorruptDecision) } defaultIndex = index } if candidate.ProviderID == "" || candidate.ModelID == "" || candidate.ProfileID == "" || candidate.ProfileRevision == "" || candidate.Reason == "" { return fmt.Errorf( "%w: candidate %d identity or reason is incomplete", ErrCorruptDecision, index, ) } if !candidate.Evaluated && (candidate.Eligible || candidate.Used) { return fmt.Errorf( "%w: unevaluated candidate %d is eligible or used", ErrCorruptDecision, index, ) } if candidate.Eligible != candidate.Used { return fmt.Errorf( "%w: candidate %d eligibility and usage disagree", ErrCorruptDecision, index, ) } if usedIndex < 0 && !candidate.Evaluated { return fmt.Errorf( "%w: candidate %d was skipped before the first match", ErrCorruptDecision, index, ) } if usedIndex >= 0 && candidate.Evaluated { return fmt.Errorf( "%w: candidate %d was evaluated after the first match", ErrCorruptDecision, index, ) } if !candidate.Evaluated && candidate.Reason != CandidateReasonNotEvaluatedAfterFirstHit { return fmt.Errorf( "%w: candidate %d has invalid unevaluated reason", ErrCorruptDecision, index, ) } if candidate.Used { if usedIndex >= 0 || (candidate.Reason != CandidateReasonMatched && candidate.Reason != CandidateReasonDefault) { return fmt.Errorf("%w: used candidate evidence is invalid", ErrCorruptDecision) } if (candidate.Source == CandidateSourceRule && candidate.Reason != CandidateReasonMatched) || (candidate.Source == CandidateSourceDefault && candidate.Reason != CandidateReasonDefault) { return fmt.Errorf( "%w: used candidate source and reason disagree", ErrCorruptDecision, ) } usedIndex = index } } if usedIndex < 0 { return fmt.Errorf("%w: no used candidate", ErrCorruptDecision) } selected := decision.Candidates[usedIndex] if selected.ProviderID != decision.ProviderID || selected.ModelID != decision.ModelID || selected.ProfileID != decision.ProfileID || selected.ProfileRevision != decision.ProfileRevision || selected.RuleID != decision.SelectedRuleID || selected.Reason != decision.SelectedReason { return fmt.Errorf("%w: selected candidate and decision disagree", ErrCorruptDecision) } if decision.History.DecisionID == "" || decision.History.SelectedAt.IsZero() || len(decision.History.UsedRoutes) == 0 { return fmt.Errorf("%w: route history is incomplete", ErrCorruptDecision) } for index, route := range decision.History.UsedRoutes { if route.ProviderID == "" || route.ModelID == "" || route.ProfileID == "" || route.ProfileRevision == "" || route.Reason == "" || route.UsedAt.IsZero() { return fmt.Errorf( "%w: used route %d identity or reason is incomplete", ErrCorruptDecision, index, ) } } used := decision.History.UsedRoutes[len(decision.History.UsedRoutes)-1] if used.ProviderID != decision.ProviderID || used.ModelID != decision.ModelID || used.ProfileID != decision.ProfileID || used.ProfileRevision != decision.ProfileRevision || used.RuleID != decision.SelectedRuleID || used.Reason != decision.SelectedReason || !used.UsedAt.Equal(decision.History.SelectedAt) { return fmt.Errorf("%w: latest used route and decision disagree", ErrCorruptDecision) } return nil } func decodeStrictJSON(data []byte, destination any) error { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(destination); err != nil { return err } var trailing any if err := decoder.Decode(&trailing); err != io.EOF { if err == nil { return errors.New("multiple JSON documents") } return err } return nil } func decisionIntegrity( version, configRevision, selectionRevision string, decisionData []byte, ) string { hash := sha256.New() writeLengthPrefixed(hash, []byte(version)) writeLengthPrefixed(hash, []byte(configRevision)) writeLengthPrefixed(hash, []byte(selectionRevision)) writeLengthPrefixed(hash, decisionData) return "sha256:" + hex.EncodeToString(hash.Sum(nil)) } func digestParts(parts ...string) string { hash := sha256.New() for _, part := range parts { writeLengthPrefixed(hash, []byte(part)) } return "sha256:" + hex.EncodeToString(hash.Sum(nil)) } func writeLengthPrefixed(destination io.Writer, value []byte) { var length [8]byte binary.BigEndian.PutUint64(length[:], uint64(len(value))) _, _ = destination.Write(length[:]) _, _ = destination.Write(value) } func constantBytesEqual(left, right string) bool { return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 } // Typed errors for decision encode/decode and evaluation. var ( ErrCorruptDecision = errors.New("agentpolicy: corrupt route decision") ErrRevisionMismatch = errors.New("agentpolicy: route decision revision mismatch") ErrNoMatch = errors.New("agentpolicy: no matching selection rule and no default target") ErrUnknownProject = errors.New("agentpolicy: unknown project") ErrUnknownProfile = errors.New("agentpolicy: unknown profile in selection target") ErrTargetIdentityMismatch = errors.New("agentpolicy: selection target identity mismatch") ErrInvalidSelectionContext = errors.New("agentpolicy: invalid selection context") )