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 }