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
212 lines
7.1 KiB
Go
212 lines
7.1 KiB
Go
// Package runtime implements the FLUID deterministic data plane: the gateway,
|
|
// revision resolution, routing, backend connection and telemetry emission.
|
|
//
|
|
// Nothing here may depend on the evolution control plane to serve a request.
|
|
// ArchitectureBlueprint.md section 2 makes this an invariant: the interface
|
|
// runtime must continue to function when the Daimon, the model provider, the
|
|
// hypothesis store, the experiment controller and the AI budget are all
|
|
// unavailable.
|
|
package runtime
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
|
"github.com/tegwick/fluid-core/internal/signing"
|
|
)
|
|
|
|
// Registry is the gateway's cached view of published control-plane state.
|
|
//
|
|
// It is deliberately a snapshot rather than a client. When the control plane
|
|
// dies the registry keeps answering from what it last held, which is what
|
|
// ArchitectureBlueprint.md section 34.6 requires: the runtime continues using
|
|
// cached published configuration, and no new promotions occur until
|
|
// control-plane consistency is restored.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
iface contract.InterfaceID
|
|
revisions map[contract.RevisionID]contract.Revision
|
|
policy contract.RoutingPolicy
|
|
hasPolicy bool
|
|
|
|
// verifier, when set, is applied to every descriptor before it is accepted.
|
|
verifier *signing.Verifier
|
|
}
|
|
|
|
// NewRegistry returns a registry that accepts unsigned descriptors.
|
|
//
|
|
// This is for development and tests. A deployment that routes real traffic
|
|
// should use NewVerifiedRegistry: ArchitectureBlueprint.md section 35 requires
|
|
// the router to accept only signed published descriptors, and a registry that
|
|
// takes anything makes the whole verification pipeline optional.
|
|
func NewRegistry(iface contract.InterfaceID) *Registry {
|
|
return &Registry{
|
|
iface: iface,
|
|
revisions: make(map[contract.RevisionID]contract.Revision),
|
|
}
|
|
}
|
|
|
|
// NewVerifiedRegistry returns a registry that refuses any descriptor which does
|
|
// not carry a signature from a trusted key.
|
|
func NewVerifiedRegistry(iface contract.InterfaceID, v *signing.Verifier) *Registry {
|
|
r := NewRegistry(iface)
|
|
r.verifier = v
|
|
return r
|
|
}
|
|
|
|
var (
|
|
// ErrUnknownRevision is returned for a revision the registry has never seen.
|
|
ErrUnknownRevision = errors.New("unknown revision")
|
|
// ErrRevisionNotRoutable is returned for a revision that exists but must not
|
|
// receive traffic.
|
|
ErrRevisionNotRoutable = errors.New("revision not routable")
|
|
// ErrNoPolicy is returned before any routing policy has been loaded.
|
|
ErrNoPolicy = errors.New("no routing policy loaded")
|
|
// ErrWrongInterface guards against loading another interface's artifacts.
|
|
ErrWrongInterface = errors.New("artifact belongs to a different interface")
|
|
// ErrStalePolicy is returned when an older policy generation is offered.
|
|
ErrStalePolicy = errors.New("routing policy generation is not newer")
|
|
)
|
|
|
|
// PutRevision publishes a revision descriptor into the registry.
|
|
//
|
|
// The descriptor is expected to have been signature-verified already; this
|
|
// method enforces only the structural conditions the router depends on.
|
|
func (r *Registry) PutRevision(d contract.Revision) error {
|
|
if d.Interface != r.iface {
|
|
return fmt.Errorf("%w: descriptor is for %q, registry serves %q",
|
|
ErrWrongInterface, d.Interface, r.iface)
|
|
}
|
|
if d.Runtime.Upstream == "" {
|
|
return fmt.Errorf("revision %s: descriptor has no runtime upstream", d.ID)
|
|
}
|
|
if !d.State.Valid() {
|
|
return fmt.Errorf("revision %s: unknown state %q", d.ID, d.State)
|
|
}
|
|
|
|
if r.verifier != nil {
|
|
if err := r.verifyDescriptor(d); err != nil {
|
|
return fmt.Errorf("revision %s: %w", d.ID, err)
|
|
}
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.revisions[d.ID] = d
|
|
return nil
|
|
}
|
|
|
|
// Revision returns a published descriptor.
|
|
func (r *Registry) Revision(id contract.RevisionID) (contract.Revision, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
d, ok := r.revisions[id]
|
|
if !ok {
|
|
return contract.Revision{}, fmt.Errorf("%w: %s", ErrUnknownRevision, id)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// PutPolicy installs a routing policy.
|
|
//
|
|
// Generations are monotonic: an older policy is refused rather than applied.
|
|
// Without this a delayed delivery could silently roll traffic back to a
|
|
// superseded allocation, which would corrupt an in-flight experiment's
|
|
// measurement window.
|
|
func (r *Registry) PutPolicy(p contract.RoutingPolicy) error {
|
|
if p.Interface != r.iface {
|
|
return fmt.Errorf("%w: policy is for %q, registry serves %q",
|
|
ErrWrongInterface, p.Interface, r.iface)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if r.hasPolicy && p.Generation <= r.policy.Generation {
|
|
return fmt.Errorf("%w: offered %d, holding %d",
|
|
ErrStalePolicy, p.Generation, r.policy.Generation)
|
|
}
|
|
r.policy = p
|
|
r.hasPolicy = true
|
|
return nil
|
|
}
|
|
|
|
// Policy returns the current routing policy.
|
|
func (r *Registry) Policy() (contract.RoutingPolicy, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
if !r.hasPolicy {
|
|
return contract.RoutingPolicy{}, ErrNoPolicy
|
|
}
|
|
return r.policy, nil
|
|
}
|
|
|
|
// routableStates lists the descriptor states the router may send traffic to.
|
|
//
|
|
// ArchitectureBlueprint.md section 5.3 requires rejecting unpublished, failed
|
|
// and retired revisions. "created" is unpublished; "verified" has passed tests
|
|
// but has not been exposed; "retired" is finished.
|
|
var routableStates = map[contract.RevisionState]bool{
|
|
contract.RevisionStateExperiment: true,
|
|
contract.RevisionStateCandidate: true,
|
|
contract.RevisionStateStable: true,
|
|
contract.RevisionStateDeprecated: true,
|
|
}
|
|
|
|
// CheckRoutable reports whether a revision may currently receive traffic from
|
|
// the given cohort.
|
|
func (r *Registry) CheckRoutable(id contract.RevisionID, cohort contract.CohortID) error {
|
|
d, err := r.Revision(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !routableStates[d.State] {
|
|
return fmt.Errorf("%w: %s is %s", ErrRevisionNotRoutable, id, d.State)
|
|
}
|
|
if d.Policy.SecurityCheck != contract.RevisionPolicySecurityCheckPassed {
|
|
return fmt.Errorf("%w: %s has security_check=%s",
|
|
ErrRevisionNotRoutable, id, d.Policy.SecurityCheck)
|
|
}
|
|
if d.Policy.PolicyCheck != nil && *d.Policy.PolicyCheck == contract.RevisionPolicyPolicyCheckFailed {
|
|
return fmt.Errorf("%w: %s failed its policy check", ErrRevisionNotRoutable, id)
|
|
}
|
|
|
|
if d.Routing != nil && len(d.Routing.EligibleCohorts) > 0 {
|
|
if !containsCohort(d.Routing.EligibleCohorts, cohort) {
|
|
return fmt.Errorf("%w: cohort %q is not eligible for %s",
|
|
ErrRevisionNotRoutable, cohort, id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// verifyDescriptor checks a descriptor's signature.
|
|
//
|
|
// The descriptor is verified in the exact form it was signed: the document
|
|
// wrapper included, the signature member excluded. Anything else would let a
|
|
// re-wrapped descriptor pass under a signature made for different bytes.
|
|
func (r *Registry) verifyDescriptor(d contract.Revision) error {
|
|
if d.Signature == nil {
|
|
return signing.ErrUnsigned
|
|
}
|
|
return r.verifier.Verify(
|
|
contract.RevisionDescriptorDocument{Revision: d},
|
|
&signing.Signature{
|
|
Algorithm: string(d.Signature.Algorithm),
|
|
KeyID: d.Signature.KeyID,
|
|
Value: d.Signature.Value,
|
|
},
|
|
)
|
|
}
|
|
|
|
func containsCohort(list []contract.CohortID, want contract.CohortID) bool {
|
|
for _, c := range list {
|
|
if c == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|