300 lines
9.2 KiB
Go
300 lines
9.2 KiB
Go
|
|
package publish
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
||
|
|
"github.com/tegwick/fluid-core/internal/intent"
|
||
|
|
"github.com/tegwick/fluid-core/internal/policy"
|
||
|
|
"github.com/tegwick/fluid-core/internal/signing"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Check runs one deterministic verification stage.
|
||
|
|
//
|
||
|
|
// Blueprint section 15 lists contract, regression, security, backend contract,
|
||
|
|
// performance, complexity and static policy checks. Implementations plug in
|
||
|
|
// here; the pipeline only cares that each returns a verdict and its evidence.
|
||
|
|
type Check interface {
|
||
|
|
// Stage names which pipeline step this check belongs to.
|
||
|
|
Stage() Stage
|
||
|
|
// Run reports whether the candidate passes, with evidence either way.
|
||
|
|
Run(context.Context, Candidate) StageResult
|
||
|
|
}
|
||
|
|
|
||
|
|
// CheckFunc adapts a function to Check.
|
||
|
|
type CheckFunc struct {
|
||
|
|
StageName Stage
|
||
|
|
Fn func(context.Context, Candidate) StageResult
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stage implements Check.
|
||
|
|
func (c CheckFunc) Stage() Stage { return c.StageName }
|
||
|
|
|
||
|
|
// Run implements Check.
|
||
|
|
func (c CheckFunc) Run(ctx context.Context, cand Candidate) StageResult {
|
||
|
|
return c.Fn(ctx, cand)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Pipeline implements the Blueprint section 35 publication pipeline.
|
||
|
|
//
|
||
|
|
// It is the only path from Candidate to Verified. Nothing else in the codebase
|
||
|
|
// constructs a Verified value, which is what makes "AI-generated artifacts are
|
||
|
|
// untrusted until verified" (invariant 9) a property of the code rather than a
|
||
|
|
// rule people are asked to remember.
|
||
|
|
type Pipeline struct {
|
||
|
|
checks []Check
|
||
|
|
gate *policy.Gate
|
||
|
|
signer *signing.Signer
|
||
|
|
store evidence.Store
|
||
|
|
intents *intent.Store
|
||
|
|
now func() time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// Options configures a pipeline.
|
||
|
|
type Options struct {
|
||
|
|
// Checks run in pipeline order. A stage with no check is recorded as passed
|
||
|
|
// with an explicit note, so a gap in verification is visible in the report
|
||
|
|
// rather than invisible by omission.
|
||
|
|
Checks []Check
|
||
|
|
// Gate is the deterministic policy gate. Required.
|
||
|
|
Gate *policy.Gate
|
||
|
|
// Signer produces the signature that makes a descriptor routable. Required.
|
||
|
|
Signer *signing.Signer
|
||
|
|
// Store records the audit trail. Required.
|
||
|
|
Store evidence.Store
|
||
|
|
// Intents resolves the governing intent for a candidate. Required.
|
||
|
|
Intents *intent.Store
|
||
|
|
}
|
||
|
|
|
||
|
|
// New returns a pipeline.
|
||
|
|
func New(o Options) (*Pipeline, error) {
|
||
|
|
switch {
|
||
|
|
case o.Gate == nil:
|
||
|
|
return nil, fmt.Errorf("pipeline requires a policy gate")
|
||
|
|
case o.Signer == nil:
|
||
|
|
return nil, fmt.Errorf("pipeline requires a signer")
|
||
|
|
case o.Store == nil:
|
||
|
|
return nil, fmt.Errorf("pipeline requires an evidence store")
|
||
|
|
case o.Intents == nil:
|
||
|
|
return nil, fmt.Errorf("pipeline requires an intent store")
|
||
|
|
}
|
||
|
|
return &Pipeline{
|
||
|
|
checks: o.Checks,
|
||
|
|
gate: o.Gate,
|
||
|
|
signer: o.Signer,
|
||
|
|
store: o.Store,
|
||
|
|
intents: o.Intents,
|
||
|
|
now: time.Now,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// PromotionRequest carries what the gate needs beyond the descriptor itself.
|
||
|
|
type PromotionRequest struct {
|
||
|
|
AdaptationClasses []contract.AdaptationClass
|
||
|
|
ComplexityDelta float64
|
||
|
|
RequestedTrafficShare float64
|
||
|
|
Approved bool
|
||
|
|
ApprovedBy *contract.Actor
|
||
|
|
}
|
||
|
|
|
||
|
|
// Run takes a candidate through every stage.
|
||
|
|
//
|
||
|
|
// On failure it returns the report alongside the error: a rejected candidate is
|
||
|
|
// normal (invariant 14), and the reasons are evidence worth keeping rather than
|
||
|
|
// an exception to discard.
|
||
|
|
func (p *Pipeline) Run(ctx context.Context, cand Candidate, req PromotionRequest) (Verified, Report, error) {
|
||
|
|
report := Report{Revision: cand.ID()}
|
||
|
|
|
||
|
|
byStage := map[Stage][]Check{}
|
||
|
|
for _, c := range p.checks {
|
||
|
|
byStage[c.Stage()] = append(byStage[c.Stage()], c)
|
||
|
|
}
|
||
|
|
|
||
|
|
governing, intentErr := p.intents.GoverningIntent(ctx, cand.ID())
|
||
|
|
if intentErr != nil {
|
||
|
|
// Fall back to the active intent: a candidate is normally bound to its
|
||
|
|
// intent at publish time, which has not happened yet.
|
||
|
|
governing, intentErr = p.intents.Active(ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, stage := range Stages {
|
||
|
|
switch stage {
|
||
|
|
case StageSign:
|
||
|
|
// Signing is not a check; it is what the checks earn.
|
||
|
|
continue
|
||
|
|
case StagePublish:
|
||
|
|
continue
|
||
|
|
case StagePolicyCheck:
|
||
|
|
result := p.runPolicyGate(cand, req, governing, intentErr)
|
||
|
|
report.Stages = append(report.Stages, result)
|
||
|
|
p.record(ctx, cand, result)
|
||
|
|
if !result.Passed {
|
||
|
|
return Verified{}, report, &ErrRejected{Revision: cand.ID(), Failure: result}
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
checks := byStage[stage]
|
||
|
|
if len(checks) == 0 {
|
||
|
|
// An unchecked stage is recorded as such. Silence here would let a
|
||
|
|
// pipeline with no security check look identical to one that passed.
|
||
|
|
result := StageResult{
|
||
|
|
Stage: stage,
|
||
|
|
Passed: true,
|
||
|
|
Detail: "no check configured for this stage",
|
||
|
|
}
|
||
|
|
report.Stages = append(report.Stages, result)
|
||
|
|
p.record(ctx, cand, result)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, c := range checks {
|
||
|
|
result := c.Run(ctx, cand)
|
||
|
|
result.Stage = stage
|
||
|
|
report.Stages = append(report.Stages, result)
|
||
|
|
p.record(ctx, cand, result)
|
||
|
|
if !result.Passed {
|
||
|
|
return Verified{}, report, &ErrRejected{Revision: cand.ID(), Failure: result}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
sig, err := p.signer.Sign(contract.RevisionDescriptorDocument{Revision: cand.Descriptor()})
|
||
|
|
if err != nil {
|
||
|
|
result := StageResult{Stage: StageSign, Passed: false, Detail: err.Error()}
|
||
|
|
report.Stages = append(report.Stages, result)
|
||
|
|
p.record(ctx, cand, result)
|
||
|
|
return Verified{}, report, &ErrRejected{Revision: cand.ID(), Failure: result}
|
||
|
|
}
|
||
|
|
|
||
|
|
signResult := StageResult{
|
||
|
|
Stage: StageSign,
|
||
|
|
Passed: true,
|
||
|
|
Detail: fmt.Sprintf("signed with key %s", sig.KeyID),
|
||
|
|
}
|
||
|
|
report.Stages = append(report.Stages, signResult)
|
||
|
|
p.record(ctx, cand, signResult)
|
||
|
|
|
||
|
|
return Verified{
|
||
|
|
descriptor: signedDescriptor(cand.Descriptor(), sig),
|
||
|
|
origin: cand.Origin(),
|
||
|
|
report: report,
|
||
|
|
}, report, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// runPolicyGate applies the deterministic gate.
|
||
|
|
func (p *Pipeline) runPolicyGate(cand Candidate, req PromotionRequest, governing intent.Version, intentErr error) StageResult {
|
||
|
|
if intentErr != nil {
|
||
|
|
// No governing intent means no constitutional basis for the change.
|
||
|
|
// Blueprint invariant 5 requires every revision to be governed by a
|
||
|
|
// specific intent version, so this is a refusal, not a warning.
|
||
|
|
return StageResult{
|
||
|
|
Stage: StagePolicyCheck,
|
||
|
|
Passed: false,
|
||
|
|
Detail: fmt.Sprintf("no governing interface evolution intent: %v", intentErr),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
decision := p.gate.Evaluate(policy.Input{
|
||
|
|
Descriptor: cand.Descriptor(),
|
||
|
|
GoverningMode: governing.Mode,
|
||
|
|
AdaptationClasses: req.AdaptationClasses,
|
||
|
|
ComplexityDelta: req.ComplexityDelta,
|
||
|
|
RequestedTrafficShare: req.RequestedTrafficShare,
|
||
|
|
Approved: req.Approved,
|
||
|
|
ApprovedBy: req.ApprovedBy,
|
||
|
|
})
|
||
|
|
|
||
|
|
if decision.Allowed {
|
||
|
|
return StageResult{
|
||
|
|
Stage: StagePolicyCheck,
|
||
|
|
Passed: true,
|
||
|
|
Detail: fmt.Sprintf("passed under %s at %s", governing.Version, governing.Mode),
|
||
|
|
Evidence: []string{"intent:" + governing.Version},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return StageResult{
|
||
|
|
Stage: StagePolicyCheck,
|
||
|
|
Passed: false,
|
||
|
|
Detail: joinReasons(decision.Reasons),
|
||
|
|
Evidence: decision.Reasons,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// record appends a stage outcome to the audit trail.
|
||
|
|
//
|
||
|
|
// Every arrow in the pipeline should be independently observable (Blueprint
|
||
|
|
// section 46), so stages are recorded as they happen rather than summarized at
|
||
|
|
// the end — a pipeline that crashes mid-run still leaves evidence of how far it
|
||
|
|
// got.
|
||
|
|
func (p *Pipeline) record(ctx context.Context, cand Candidate, r StageResult) {
|
||
|
|
verdict := "PASSED"
|
||
|
|
if !r.Passed {
|
||
|
|
verdict = "FAILED"
|
||
|
|
}
|
||
|
|
|
||
|
|
_ = p.store.AppendEvent(ctx, contract.FluidEvent{
|
||
|
|
SchemaVersion: "0.1",
|
||
|
|
ID: contract.EventID(fmt.Sprintf("EV-%s-%s-%d",
|
||
|
|
cand.ID(), r.Stage, p.now().UnixNano())),
|
||
|
|
OccurredAt: p.now().UTC(),
|
||
|
|
EntityType: contract.FluidEventEntityTypeRevision,
|
||
|
|
EntityID: string(cand.ID()),
|
||
|
|
EventType: fmt.Sprintf("%s_%s", r.Stage, verdict),
|
||
|
|
Actor: cand.Origin(),
|
||
|
|
Reason: r.Detail,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Publish records a verified revision and binds it to its governing intent.
|
||
|
|
//
|
||
|
|
// It takes a Verified rather than a Candidate: the type signature is the
|
||
|
|
// enforcement. There is no overload that accepts an unverified descriptor.
|
||
|
|
func (p *Pipeline) Publish(ctx context.Context, v Verified) error {
|
||
|
|
d := v.Descriptor()
|
||
|
|
|
||
|
|
if d.Signature == nil {
|
||
|
|
return ErrNotVerified
|
||
|
|
}
|
||
|
|
|
||
|
|
body, err := json.Marshal(d)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := p.store.PutRecord(ctx, contract.KindRevision, string(d.ID), body); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := p.intents.Bind(ctx, d.ID, d.Intent.Version); err != nil {
|
||
|
|
return fmt.Errorf("published %s but failed to bind its intent: %w", d.ID, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return p.store.AppendEvent(ctx, contract.FluidEvent{
|
||
|
|
SchemaVersion: "0.1",
|
||
|
|
ID: contract.EventID(fmt.Sprintf("EV-%s-PUBLISH-%d", d.ID, p.now().UnixNano())),
|
||
|
|
OccurredAt: p.now().UTC(),
|
||
|
|
EntityType: contract.FluidEventEntityTypeRevision,
|
||
|
|
EntityID: string(d.ID),
|
||
|
|
EventType: "REVISION_PUBLISHED",
|
||
|
|
Actor: v.Origin(),
|
||
|
|
Inputs: []string{d.Intent.Version},
|
||
|
|
Reason: fmt.Sprintf("published %s in state %s, governed by %s, signed by %s",
|
||
|
|
d.ID, d.State, d.Intent.Version, d.Signature.KeyID),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func joinReasons(reasons []string) string {
|
||
|
|
if len(reasons) == 0 {
|
||
|
|
return "rejected"
|
||
|
|
}
|
||
|
|
s := reasons[0]
|
||
|
|
for _, r := range reasons[1:] {
|
||
|
|
s += "; " + r
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|