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
283 lines
7.9 KiB
Go
283 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|