fluid-core/internal/publish/pipeline.go
tegwick a2d561eae5 Add signing, trust types, policy gate and publication pipeline
FLUID-WP-0004 T01, T02, T05, T06. The pipeline is the only path from
Candidate to Verified: no other code constructs a Verified value, and
Publish takes one, so "AI-generated artifacts are untrusted until
verified" is a property of the type signatures rather than a rule people
are asked to remember.

The policy gate is a pure function of the candidate, the governing
intent and configured limits. It cannot consult a model or take an
opinion as input, because a gate that can be argued with is not a gate.
Two behaviours it enforces are worth naming: the tighter of the
descriptor's own traffic ceiling and the gate's wins, so a descriptor
can restrict itself but never widen; and a daimon cannot authorize its
own promotion below FLUID-5, since generation authority is not promotion
authority.

Signatures cover the canonical document with the signature member
removed, so a signed descriptor round-trips and a tampered one does not.
The registry now refuses anything that does not verify, which is what
makes the pipeline's signature mean something at the router.

An unchecked pipeline stage is recorded as unchecked rather than
omitted, so a pipeline with no security check cannot look identical to
one that passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:56:30 +02:00

299 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
}