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