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
This commit is contained in:
parent
d52dcc92a9
commit
a2d561eae5
9 changed files with 1679 additions and 1 deletions
299
internal/publish/pipeline.go
Normal file
299
internal/publish/pipeline.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
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
|
||||
}
|
||||
283
internal/publish/pipeline_test.go
Normal file
283
internal/publish/pipeline_test.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const intentDoc = `# Interface Evolution Intent
|
||||
|
||||
**Current operational authority mode:**
|
||||
FLUID-2
|
||||
`
|
||||
|
||||
type fixture struct {
|
||||
pipeline *Pipeline
|
||||
store *evidence.SQLStore
|
||||
intents *intent.Store
|
||||
pub ed25519.PublicKey
|
||||
keyID string
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, limits policy.Limits, checks ...Check) *fixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
store, err := evidence.OpenSQLite(ctx, filepath.Join(t.TempDir(), "e.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
intents := intent.New(store, "hall-publishing")
|
||||
if _, err := intents.Put(ctx, "IEI-1", intentDoc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := intents.SetActive(ctx, "IEI-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
signer, pub, err := signing.GenerateKey("test-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p, err := New(Options{
|
||||
Checks: checks,
|
||||
Gate: policy.NewGate(limits),
|
||||
Signer: signer,
|
||||
Store: store,
|
||||
Intents: intents,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &fixture{pipeline: p, store: store, intents: intents, pub: pub, keyID: signer.KeyID()}
|
||||
}
|
||||
|
||||
func candidate() Candidate {
|
||||
pc := contract.RevisionPolicyPolicyCheckPassed
|
||||
return NewCandidate(contract.Revision{
|
||||
SchemaVersion: "0.1",
|
||||
ID: "R-2",
|
||||
Interface: "hall-publishing",
|
||||
State: contract.RevisionStateCandidate,
|
||||
Contract: contract.RevisionContract{
|
||||
Type: contract.RevisionContractTypeOpenapi,
|
||||
Digest: contract.Digest("sha256:" + strings.Repeat("1", 64)),
|
||||
},
|
||||
Runtime: contract.RevisionRuntime{Upstream: "http://adapter:8080"},
|
||||
Intent: contract.RevisionIntent{Version: "IEI-1"},
|
||||
Policy: contract.RevisionPolicy{
|
||||
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
|
||||
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
|
||||
PolicyCheck: &pc,
|
||||
},
|
||||
}, contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"})
|
||||
}
|
||||
|
||||
func request() PromotionRequest {
|
||||
return PromotionRequest{
|
||||
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
|
||||
ComplexityDelta: 0.2,
|
||||
RequestedTrafficShare: 0.10,
|
||||
Approved: true,
|
||||
ApprovedBy: &contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"},
|
||||
}
|
||||
}
|
||||
|
||||
func passingCheck(stage Stage) Check {
|
||||
return CheckFunc{StageName: stage, Fn: func(context.Context, Candidate) StageResult {
|
||||
return StageResult{Passed: true, Detail: "ok", Evidence: []string{"test:" + string(stage)}}
|
||||
}}
|
||||
}
|
||||
|
||||
func failingCheck(stage Stage, why string) Check {
|
||||
return CheckFunc{StageName: stage, Fn: func(context.Context, Candidate) StageResult {
|
||||
return StageResult{Passed: false, Detail: why}
|
||||
}}
|
||||
}
|
||||
|
||||
func TestPipelineVerifiesSignsAndPublishes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newFixture(t, policy.DefaultLimits(),
|
||||
passingCheck(StageContractCheck),
|
||||
passingCheck(StageTest),
|
||||
passingCheck(StageSecurityCheck),
|
||||
)
|
||||
|
||||
verified, report, err := f.pipeline.Run(ctx, candidate(), request())
|
||||
if err != nil {
|
||||
t.Fatalf("pipeline rejected a conforming candidate: %v", err)
|
||||
}
|
||||
if !report.Passed() {
|
||||
t.Fatalf("report says not passed: %+v", report.Stages)
|
||||
}
|
||||
|
||||
// The signature is what the checks earn, and it must verify against the
|
||||
// signer's key.
|
||||
d := verified.Descriptor()
|
||||
if d.Signature == nil {
|
||||
t.Fatal("verified descriptor carries no signature")
|
||||
}
|
||||
v := signing.NewVerifier(map[string]ed25519.PublicKey{f.keyID: f.pub})
|
||||
sig := &signing.Signature{
|
||||
Algorithm: string(d.Signature.Algorithm),
|
||||
KeyID: d.Signature.KeyID,
|
||||
Value: d.Signature.Value,
|
||||
}
|
||||
if err := v.Verify(contract.RevisionDescriptorDocument{Revision: d}, sig); err != nil {
|
||||
t.Fatalf("signature does not verify: %v", err)
|
||||
}
|
||||
|
||||
if err := f.pipeline.Publish(ctx, verified); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
// Publication must bind the revision to its governing intent, or the
|
||||
// Blueprint 27 audit question becomes unanswerable.
|
||||
got, err := f.intents.GoverningIntent(ctx, "R-2")
|
||||
if err != nil {
|
||||
t.Fatalf("no governing intent recorded: %v", err)
|
||||
}
|
||||
if got.Version != "IEI-1" {
|
||||
t.Errorf("governing intent = %s, want IEI-1", got.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineStopsAtFirstFailingStage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newFixture(t, policy.DefaultLimits(),
|
||||
passingCheck(StageContractCheck),
|
||||
failingCheck(StageTest, "regression suite failed: 3 of 41"),
|
||||
passingCheck(StageSecurityCheck),
|
||||
)
|
||||
|
||||
_, report, err := f.pipeline.Run(ctx, candidate(), request())
|
||||
if err == nil {
|
||||
t.Fatal("pipeline accepted a candidate whose tests failed")
|
||||
}
|
||||
|
||||
var rejected *ErrRejected
|
||||
if !errors.As(err, &rejected) {
|
||||
t.Fatalf("got %T, want *ErrRejected", err)
|
||||
}
|
||||
if rejected.Failure.Stage != StageTest {
|
||||
t.Errorf("failed at %s, want TEST", rejected.Failure.Stage)
|
||||
}
|
||||
|
||||
// Nothing after the failure should have run: a security check that never
|
||||
// executed must not appear as passed.
|
||||
for _, s := range report.Stages {
|
||||
if s.Stage == StageSecurityCheck {
|
||||
t.Error("a stage after the failure was executed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncheckedStagesAreVisible guards against a pipeline with no security
|
||||
// check looking identical to one that passed.
|
||||
func TestUncheckedStagesAreVisible(t *testing.T) {
|
||||
f := newFixture(t, policy.DefaultLimits())
|
||||
|
||||
_, report, err := f.pipeline.Run(context.Background(), candidate(), request())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var security StageResult
|
||||
for _, s := range report.Stages {
|
||||
if s.Stage == StageSecurityCheck {
|
||||
security = s
|
||||
}
|
||||
}
|
||||
if security.Stage == "" {
|
||||
t.Fatal("security stage missing from the report entirely")
|
||||
}
|
||||
if !strings.Contains(security.Detail, "no check configured") {
|
||||
t.Errorf("an unchecked stage does not say so: %q", security.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyGateRejectionIsRecorded confirms the gate sits inside the pipeline
|
||||
// rather than beside it.
|
||||
func TestPolicyGateRejectionIsRecorded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newFixture(t, policy.DefaultLimits())
|
||||
|
||||
req := request()
|
||||
req.Approved = false
|
||||
req.ApprovedBy = nil
|
||||
|
||||
_, _, err := f.pipeline.Run(ctx, candidate(), req)
|
||||
if err == nil {
|
||||
t.Fatal("an unapproved candidate was verified")
|
||||
}
|
||||
|
||||
events, qerr := f.store.Events(ctx, evidence.EventFilter{EntityID: "R-2"})
|
||||
if qerr != nil {
|
||||
t.Fatal(qerr)
|
||||
}
|
||||
found := false
|
||||
for _, ev := range events {
|
||||
if ev.EventType == "POLICY_CHECK_FAILED" {
|
||||
found = true
|
||||
if !strings.Contains(ev.Reason, "authorization") {
|
||||
t.Errorf("rejection reason not recorded usefully: %q", ev.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the policy gate rejection left no audit event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublishRequiresVerification is the type-level check behind invariant 9.
|
||||
// A zero Verified value has no signature and must be refused.
|
||||
func TestPublishRequiresVerification(t *testing.T) {
|
||||
f := newFixture(t, policy.DefaultLimits())
|
||||
if err := f.pipeline.Publish(context.Background(), Verified{}); !errors.Is(err, ErrNotVerified) {
|
||||
t.Errorf("publishing an unverified value returned %v, want ErrNotVerified", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryStageLeavesAnEvent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newFixture(t, policy.DefaultLimits(), passingCheck(StageTest))
|
||||
|
||||
if _, _, err := f.pipeline.Run(ctx, candidate(), request()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
events, err := f.store.Events(ctx, evidence.EventFilter{EntityID: "R-2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Every arrow should be independently observable (Blueprint 46).
|
||||
seen := map[string]bool{}
|
||||
for _, ev := range events {
|
||||
seen[ev.EventType] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"SOURCE_PASSED", "BUILD_PASSED", "CONTRACT_CHECK_PASSED",
|
||||
"TEST_PASSED", "SECURITY_CHECK_PASSED", "POLICY_CHECK_PASSED", "SIGN_PASSED",
|
||||
} {
|
||||
if !seen[want] {
|
||||
t.Errorf("no audit event for %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
154
internal/publish/trust.go
Normal file
154
internal/publish/trust.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue