fluid-core/internal/publish/trust.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

154 lines
5.1 KiB
Go

// Package publish implements the revision publication pipeline and the trust
// transitions it enforces.
//
// ArchitectureBlueprint.md section 47 assigns different trust levels to
// different things: AI interpretation is advisory, generated code is an
// untrusted candidate, deterministic tests are verification evidence, and only
// a signed revision is a deployable artifact. Section 52 asks that this
// distinction stay visible in code and data models rather than living in
// reviewers' heads.
//
// The types here make it visible. A candidate cannot be published, because the
// publish path takes a Verified value, and the only way to obtain one is to
// pass verification. That is a weaker guarantee than a proof, but it means the
// unsafe path has to be written deliberately rather than reached by accident.
package publish
import (
"errors"
"fmt"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/signing"
)
// Candidate is an unverified revision descriptor.
//
// Whatever produced it — a human, a Builder, an LLM — it carries no authority.
// The field is unexported so that a Candidate can only be made through
// NewCandidate, and cannot be forged into a Verified value by struct literal
// from another package.
type Candidate struct {
descriptor contract.Revision
origin contract.Actor
}
// NewCandidate wraps a descriptor as an untrusted candidate.
func NewCandidate(d contract.Revision, origin contract.Actor) Candidate {
return Candidate{descriptor: d, origin: origin}
}
// Descriptor returns a copy of the candidate's descriptor for inspection.
//
// It is a copy on purpose: verification decides about a specific byte sequence,
// and handing out a mutable reference would let a caller change the artifact
// after it was judged.
func (c Candidate) Descriptor() contract.Revision { return c.descriptor }
// Origin reports who or what produced the candidate.
func (c Candidate) Origin() contract.Actor { return c.origin }
// ID reports the candidate's revision id.
func (c Candidate) ID() contract.RevisionID { return c.descriptor.ID }
// Verified is a candidate that has passed every deterministic gate and been
// signed. Only a Verified value may be published and routed.
type Verified struct {
descriptor contract.Revision
origin contract.Actor
report Report
}
// Descriptor returns the verified descriptor, signature included.
func (v Verified) Descriptor() contract.Revision { return v.descriptor }
// Origin reports who or what produced the underlying candidate.
func (v Verified) Origin() contract.Actor { return v.origin }
// Report returns the evidence that justified verification.
func (v Verified) Report() Report { return v.report }
// ID reports the revision id.
func (v Verified) ID() contract.RevisionID { return v.descriptor.ID }
// Stage names a step of the publication pipeline (Blueprint section 35).
type Stage string
const (
StageSource Stage = "SOURCE"
StageBuild Stage = "BUILD"
StageContractCheck Stage = "CONTRACT_CHECK"
StageTest Stage = "TEST"
StageSecurityCheck Stage = "SECURITY_CHECK"
StagePolicyCheck Stage = "POLICY_CHECK"
StageSign Stage = "SIGN"
StagePublish Stage = "PUBLISH"
)
// Stages is the pipeline in order.
var Stages = []Stage{
StageSource, StageBuild, StageContractCheck, StageTest,
StageSecurityCheck, StagePolicyCheck, StageSign, StagePublish,
}
// StageResult records what happened at one stage.
type StageResult struct {
Stage Stage `json:"stage"`
Passed bool `json:"passed"`
Detail string `json:"detail,omitempty"`
Evidence []string `json:"evidence,omitempty"`
}
// Report is the accumulated evidence for a publication attempt.
//
// It is retained whether or not the attempt succeeded. Failed candidates are
// normal (Blueprint invariant 14) and the record of why one was rejected is
// exactly the evidence a later hypothesis needs.
type Report struct {
Revision contract.RevisionID `json:"revision"`
Stages []StageResult `json:"stages"`
}
// Passed reports whether every recorded stage passed.
func (r Report) Passed() bool {
for _, s := range r.Stages {
if !s.Passed {
return false
}
}
return len(r.Stages) > 0
}
// FirstFailure returns the stage that stopped the pipeline.
func (r Report) FirstFailure() (StageResult, bool) {
for _, s := range r.Stages {
if !s.Passed {
return s, true
}
}
return StageResult{}, false
}
// ErrRejected reports a candidate that failed a gate.
type ErrRejected struct {
Revision contract.RevisionID
Failure StageResult
}
func (e *ErrRejected) Error() string {
return fmt.Sprintf("revision %s rejected at %s: %s", e.Revision, e.Failure.Stage, e.Failure.Detail)
}
// ErrNotVerified reports an attempt to publish something unverified.
var ErrNotVerified = errors.New("revision has not passed verification")
// signedDescriptor attaches a signature to a descriptor.
func signedDescriptor(d contract.Revision, sig signing.Signature) contract.Revision {
out := d
out.Signature = &contract.RevisionSignature{
Algorithm: contract.RevisionSignatureAlgorithm(sig.Algorithm),
KeyID: sig.KeyID,
Value: sig.Value,
}
return out
}