263 lines
8.4 KiB
Go
263 lines
8.4 KiB
Go
|
|
// Package promotion records decisions about whether a candidate may progress.
|
||
|
|
//
|
||
|
|
// ArchitectureBlueprint.md section 19 lists the possible outcomes and section
|
||
|
|
// 28.1 keeps promotion authority separate from code-generation authority. This
|
||
|
|
// package records a decision and its justification; it does not make one. The
|
||
|
|
// deterministic policy gate decides what is permissible, a human or a policy
|
||
|
|
// decides what is wanted, and this is where that decision becomes evidence.
|
||
|
|
package promotion
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"sort"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
||
|
|
"github.com/tegwick/fluid-core/internal/fitness"
|
||
|
|
"github.com/tegwick/fluid-core/internal/policy"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Outcome is a promotion decision.
|
||
|
|
type Outcome string
|
||
|
|
|
||
|
|
const (
|
||
|
|
Promote Outcome = "PROMOTE"
|
||
|
|
ExpandExperiment Outcome = "EXPAND_EXPERIMENT"
|
||
|
|
RetainAsOption Outcome = "RETAIN_AS_OPTION"
|
||
|
|
Modify Outcome = "MODIFY"
|
||
|
|
Revert Outcome = "REVERT"
|
||
|
|
Abandon Outcome = "ABANDON"
|
||
|
|
Defer Outcome = "DEFER"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Valid reports whether o is a defined outcome.
|
||
|
|
func (o Outcome) Valid() bool {
|
||
|
|
switch o {
|
||
|
|
case Promote, ExpandExperiment, RetainAsOption, Modify, Revert, Abandon, Defer:
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decision is a recorded promotion decision.
|
||
|
|
type Decision struct {
|
||
|
|
ID contract.DecisionID `json:"id"`
|
||
|
|
Revision contract.RevisionID `json:"revision"`
|
||
|
|
Outcome Outcome `json:"outcome"`
|
||
|
|
Reason string `json:"reason"`
|
||
|
|
Actor contract.Actor `json:"actor"`
|
||
|
|
DecidedAt time.Time `json:"decided_at"`
|
||
|
|
|
||
|
|
// Experiment and Hypothesis tie the decision back to the evidence it rests
|
||
|
|
// on, so a later reader can check the reasoning rather than take it.
|
||
|
|
Experiment contract.ExperimentID `json:"experiment,omitempty"`
|
||
|
|
Hypotheses []contract.HypothesisID `json:"hypotheses,omitempty"`
|
||
|
|
|
||
|
|
// FitnessVerdict is the evaluator's reading at decision time.
|
||
|
|
FitnessVerdict fitness.Verdict `json:"fitness_verdict,omitempty"`
|
||
|
|
// GateDecision is the deterministic gate's verdict at decision time.
|
||
|
|
GateAllowed bool `json:"gate_allowed"`
|
||
|
|
GateReasons []string `json:"gate_reasons,omitempty"`
|
||
|
|
// Override marks a decision taken against the evidence or the gate.
|
||
|
|
Override bool `json:"override,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Controller records promotion decisions.
|
||
|
|
type Controller struct {
|
||
|
|
store evidence.Store
|
||
|
|
gate *policy.Gate
|
||
|
|
now func() time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewController returns a promotion controller.
|
||
|
|
func NewController(store evidence.Store, gate *policy.Gate) *Controller {
|
||
|
|
return &Controller{store: store, gate: gate, now: time.Now}
|
||
|
|
}
|
||
|
|
|
||
|
|
var (
|
||
|
|
// ErrGateRefused reports a promotion the deterministic gate forbids.
|
||
|
|
ErrGateRefused = errors.New("the deterministic policy gate refuses this promotion")
|
||
|
|
// ErrNoEvidence reports a promotion with nothing behind it.
|
||
|
|
ErrNoEvidence = errors.New("promotion requires a fitness verdict")
|
||
|
|
)
|
||
|
|
|
||
|
|
// Request is a proposed promotion.
|
||
|
|
type Request struct {
|
||
|
|
Revision contract.RevisionID
|
||
|
|
Outcome Outcome
|
||
|
|
Reason string
|
||
|
|
Actor contract.Actor
|
||
|
|
Experiment contract.ExperimentID
|
||
|
|
Hypotheses []contract.HypothesisID
|
||
|
|
|
||
|
|
// Evaluation is the fitness reading the decision rests on.
|
||
|
|
Evaluation *fitness.Evaluation
|
||
|
|
// GateInput lets the controller re-run the deterministic gate at decision
|
||
|
|
// time rather than trusting a verdict computed earlier, since the intent or
|
||
|
|
// the limits may have changed since.
|
||
|
|
GateInput *policy.Input
|
||
|
|
|
||
|
|
// AcknowledgeOverride is required to promote against the evidence. It does
|
||
|
|
// not disable the gate — nothing does — but it does permit promoting a
|
||
|
|
// candidate whose fitness verdict was not a success, with the override
|
||
|
|
// recorded as such.
|
||
|
|
AcknowledgeOverride bool
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decide records a promotion decision.
|
||
|
|
//
|
||
|
|
// The gate is consulted, never bypassed. A human may promote a candidate whose
|
||
|
|
// experiment was inconclusive — that is a legitimate judgement call — but they
|
||
|
|
// may not promote one the gate refuses, and choosing to act against the
|
||
|
|
// evidence is recorded as an override rather than washed into the reason text.
|
||
|
|
func (c *Controller) Decide(ctx context.Context, req Request) (Decision, error) {
|
||
|
|
if !req.Outcome.Valid() {
|
||
|
|
return Decision{}, fmt.Errorf("unknown promotion outcome %q", req.Outcome)
|
||
|
|
}
|
||
|
|
if req.Reason == "" {
|
||
|
|
return Decision{}, errors.New("a promotion decision requires a reason")
|
||
|
|
}
|
||
|
|
if req.Actor.ID == "" {
|
||
|
|
return Decision{}, errors.New("a promotion decision must name its actor")
|
||
|
|
}
|
||
|
|
|
||
|
|
d := Decision{
|
||
|
|
ID: contract.DecisionID(fmt.Sprintf("D-%s-%d", req.Revision, c.now().UnixNano())),
|
||
|
|
Revision: req.Revision,
|
||
|
|
Outcome: req.Outcome,
|
||
|
|
Reason: req.Reason,
|
||
|
|
Actor: req.Actor,
|
||
|
|
DecidedAt: c.now().UTC(),
|
||
|
|
Experiment: req.Experiment,
|
||
|
|
Hypotheses: req.Hypotheses,
|
||
|
|
GateAllowed: true,
|
||
|
|
}
|
||
|
|
|
||
|
|
if req.Evaluation != nil {
|
||
|
|
d.FitnessVerdict = req.Evaluation.Verdict
|
||
|
|
}
|
||
|
|
|
||
|
|
// Only outcomes that widen exposure need the gate. Reverting, abandoning
|
||
|
|
// or deferring reduce risk, and requiring permission to stop would be a
|
||
|
|
// safety property pointed the wrong way.
|
||
|
|
if wideningExposure(req.Outcome) {
|
||
|
|
if req.GateInput == nil {
|
||
|
|
return d, errors.New("promoting requires gate input describing the candidate")
|
||
|
|
}
|
||
|
|
decision := c.gate.Evaluate(*req.GateInput)
|
||
|
|
d.GateAllowed = decision.Allowed
|
||
|
|
d.GateReasons = decision.Reasons
|
||
|
|
|
||
|
|
if !decision.Allowed {
|
||
|
|
// Record the refusal before returning: an attempted promotion that
|
||
|
|
// the gate blocked is exactly the kind of thing an audit wants.
|
||
|
|
_ = c.record(ctx, d, "PROMOTION_REFUSED")
|
||
|
|
return d, fmt.Errorf("%w: %v", ErrGateRefused, decision.Reasons)
|
||
|
|
}
|
||
|
|
|
||
|
|
if req.Evaluation == nil {
|
||
|
|
return d, ErrNoEvidence
|
||
|
|
}
|
||
|
|
if req.Evaluation.Verdict != fitness.VerdictSucceeded {
|
||
|
|
if !req.AcknowledgeOverride {
|
||
|
|
return d, fmt.Errorf(
|
||
|
|
"fitness verdict is %s, not %s: pass an acknowledged override to promote anyway (%v)",
|
||
|
|
req.Evaluation.Verdict, fitness.VerdictSucceeded, req.Evaluation.Reasons)
|
||
|
|
}
|
||
|
|
d.Override = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := c.put(ctx, d); err != nil {
|
||
|
|
return d, err
|
||
|
|
}
|
||
|
|
return d, c.record(ctx, d, "PROMOTION_DECIDED")
|
||
|
|
}
|
||
|
|
|
||
|
|
// wideningExposure reports whether an outcome increases a candidate's reach.
|
||
|
|
func wideningExposure(o Outcome) bool {
|
||
|
|
switch o {
|
||
|
|
case Promote, ExpandExperiment:
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get returns one decision.
|
||
|
|
func (c *Controller) Get(ctx context.Context, id contract.DecisionID) (Decision, error) {
|
||
|
|
body, err := c.store.Record(ctx, contract.KindDecision, string(id))
|
||
|
|
if err != nil {
|
||
|
|
return Decision{}, err
|
||
|
|
}
|
||
|
|
var d Decision
|
||
|
|
if err := json.Unmarshal(body, &d); err != nil {
|
||
|
|
return Decision{}, fmt.Errorf("decode decision %s: %w", id, err)
|
||
|
|
}
|
||
|
|
return d, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ForRevision returns the decisions taken about a revision, oldest first.
|
||
|
|
func (c *Controller) ForRevision(ctx context.Context, rev contract.RevisionID) ([]Decision, error) {
|
||
|
|
records, err := c.store.Records(ctx, contract.KindDecision)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
var out []Decision
|
||
|
|
for _, body := range records {
|
||
|
|
var d Decision
|
||
|
|
if err := json.Unmarshal(body, &d); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if d.Revision != rev {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
out = append(out, d)
|
||
|
|
}
|
||
|
|
sort.Slice(out, func(i, j int) bool { return out[i].DecidedAt.Before(out[j].DecidedAt) })
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Controller) put(ctx context.Context, d Decision) error {
|
||
|
|
body, err := json.Marshal(d)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return c.store.PutRecord(ctx, contract.KindDecision, string(d.ID), body)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Controller) record(ctx context.Context, d Decision, kind string) error {
|
||
|
|
inputs := make([]string, 0, len(d.Hypotheses)+1)
|
||
|
|
for _, h := range d.Hypotheses {
|
||
|
|
inputs = append(inputs, string(h))
|
||
|
|
}
|
||
|
|
if d.Experiment != "" {
|
||
|
|
inputs = append(inputs, string(d.Experiment))
|
||
|
|
}
|
||
|
|
|
||
|
|
reason := d.Reason
|
||
|
|
if d.Override {
|
||
|
|
// The override is part of the record, not a footnote in the prose.
|
||
|
|
reason = fmt.Sprintf("[override: fitness verdict was %s] %s", d.FitnessVerdict, reason)
|
||
|
|
}
|
||
|
|
if !d.GateAllowed {
|
||
|
|
reason = fmt.Sprintf("[gate refused: %v] %s", d.GateReasons, reason)
|
||
|
|
}
|
||
|
|
|
||
|
|
return c.store.AppendEvent(ctx, contract.FluidEvent{
|
||
|
|
SchemaVersion: "0.1",
|
||
|
|
ID: contract.EventID(fmt.Sprintf("EV-%s-%d", d.ID, c.now().UnixNano())),
|
||
|
|
OccurredAt: d.DecidedAt,
|
||
|
|
EntityType: contract.FluidEventEntityTypeRevision,
|
||
|
|
EntityID: string(d.Revision),
|
||
|
|
EventType: fmt.Sprintf("%s_%s", kind, d.Outcome),
|
||
|
|
Actor: d.Actor,
|
||
|
|
Inputs: inputs,
|
||
|
|
Reason: reason,
|
||
|
|
})
|
||
|
|
}
|