Add evidence context policy preparation

This commit is contained in:
2026-07-27 18:04:12 +00:00
parent 64d461fc18
commit 224a8292c4
21 changed files with 900 additions and 98 deletions

View File

@@ -0,0 +1,148 @@
package pipeline
import (
"fmt"
"reflect"
"sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
// ArtifactEvidenceProjector returns the direct source references represented
// by one normalized artifact value.
type ArtifactEvidenceProjector[T any] func(T) []source.SourceRef
// ArtifactEvidenceRegistry keeps the typed projection boundary private while
// allowing preparation and execution to discover registered capabilities.
type ArtifactEvidenceRegistry struct {
entries map[contracts.ArtifactKind]artifactEvidenceEntry
}
type artifactEvidenceEntry struct {
valueType reflect.Type
project func(any) ([]source.SourceRef, error)
}
func NewArtifactEvidenceRegistry() *ArtifactEvidenceRegistry {
return &ArtifactEvidenceRegistry{entries: make(map[contracts.ArtifactKind]artifactEvidenceEntry)}
}
// RegisterArtifactEvidence registers one evidence projector for an artifact
// kind. Projectors are invoked only after an exact Go-type check.
func RegisterArtifactEvidence[T any](registry *ArtifactEvidenceRegistry, kind contracts.ArtifactKind, projector ArtifactEvidenceProjector[T]) error {
if registry == nil {
return fmt.Errorf("artifact evidence registry must not be nil")
}
if projector == nil {
return fmt.Errorf("artifact evidence projector must not be nil")
}
kind = normalizeArtifactKind(kind)
if kind == "" {
return fmt.Errorf("artifact evidence kind must not be empty")
}
if _, ok := registry.entries[kind]; ok {
return fmt.Errorf("artifact evidence %q is already registered", kind)
}
valueType := reflect.TypeFor[T]()
if registry.entries == nil {
registry.entries = make(map[contracts.ArtifactKind]artifactEvidenceEntry)
}
registry.entries[kind] = artifactEvidenceEntry{
valueType: valueType,
project: func(value any) ([]source.SourceRef, error) {
actualType := reflect.TypeOf(value)
if actualType != valueType {
return nil, newArtifactEvidenceTypeError(kind, valueType, actualType)
}
typed, ok := value.(T)
if !ok {
return nil, newArtifactEvidenceTypeError(kind, valueType, actualType)
}
return append([]source.SourceRef(nil), projector(typed)...), nil
},
}
return nil
}
func (r *ArtifactEvidenceRegistry) RegisteredKinds() []contracts.ArtifactKind {
if r == nil || len(r.entries) == 0 {
return nil
}
kinds := make([]contracts.ArtifactKind, 0, len(r.entries))
for kind := range r.entries {
kinds = append(kinds, kind)
}
sort.Slice(kinds, func(i, j int) bool { return kinds[i] < kinds[j] })
return kinds
}
// Project returns independently owned direct references for a registered
// artifact value.
func (r *ArtifactEvidenceRegistry) Project(kind contracts.ArtifactKind, value any) ([]source.SourceRef, error) {
entry, normalizedKind, err := r.entry(kind)
if err != nil {
return nil, err
}
refs, err := entry.project(value)
if err != nil {
return nil, fmt.Errorf("project artifact evidence %q: %w", normalizedKind, err)
}
return append([]source.SourceRef(nil), refs...), nil
}
func (r *ArtifactEvidenceRegistry) entry(kind contracts.ArtifactKind) (artifactEvidenceEntry, contracts.ArtifactKind, error) {
if r == nil {
return artifactEvidenceEntry{}, "", fmt.Errorf("artifact evidence registry must not be nil")
}
kind = normalizeArtifactKind(kind)
if kind == "" {
return artifactEvidenceEntry{}, "", fmt.Errorf("artifact evidence kind must not be empty")
}
entry, ok := r.entries[kind]
if !ok {
return artifactEvidenceEntry{}, kind, fmt.Errorf("artifact evidence %q is not registered", kind)
}
return entry, kind, nil
}
func (r *ArtifactEvidenceRegistry) valueType(kind contracts.ArtifactKind) (reflect.Type, bool) {
if r == nil {
return nil, false
}
entry, ok := r.entries[normalizeArtifactKind(kind)]
if !ok {
return nil, false
}
return entry.valueType, true
}
func newArtifactEvidenceTypeError(kind contracts.ArtifactKind, expected, actual reflect.Type) error {
actualName := "<nil>"
if actual != nil {
actualName = actual.String()
}
return fmt.Errorf("project artifact evidence %q: expected exact Go type %s, got %s", kind, expected, actualName)
}
func normalizeEvidenceLaneIDs(values []string) ([]string, error) {
if len(values) == 0 {
return nil, fmt.Errorf("evidence lane ids must not be empty")
}
seen := make(map[string]struct{}, len(values))
lanes := make([]string, 0, len(values))
for _, raw := range values {
lane := strings.TrimSpace(raw)
if lane == "" {
return nil, fmt.Errorf("evidence lane id must not be empty")
}
if _, ok := seen[lane]; ok {
return nil, fmt.Errorf("evidence lane id %q is duplicated", lane)
}
seen[lane] = struct{}{}
lanes = append(lanes, lane)
}
sort.Strings(lanes)
return lanes, nil
}

View File

@@ -0,0 +1,55 @@
package pipeline
import (
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestArtifactEvidenceRegistryProjectsExactRegisteredTypesWithOwnedReferences(t *testing.T) {
registry := NewArtifactEvidenceRegistry()
input := codecNotes{Items: []string{"one"}}
refs := []source.SourceRef{{SourceID: "source", StartUnitID: 2, EndUnitID: 2}}
if err := RegisterArtifactEvidence(registry, "test/notes", func(codecNotes) []source.SourceRef { return refs }); err != nil {
t.Fatalf("RegisterArtifactEvidence() error = %v, want nil", err)
}
if err := RegisterArtifactEvidence(registry, "test/score", func(codecScore) []source.SourceRef { return nil }); err != nil {
t.Fatalf("RegisterArtifactEvidence() second kind error = %v, want nil", err)
}
if got, want := registry.RegisteredKinds(), []contracts.ArtifactKind{"test/notes", "test/score"}; !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredKinds() = %#v, want %#v", got, want)
}
projected, err := registry.Project(" test/notes ", input)
if err != nil {
t.Fatalf("Project() error = %v, want nil", err)
}
projected[0].StartUnitID = 99
if refs[0].StartUnitID != 2 {
t.Fatal("Project() retained projector reference storage")
}
if _, err := registry.Project("test/notes", codecNotesAlias(input)); err == nil || !strings.Contains(err.Error(), "expected exact Go type") {
t.Fatalf("Project() exact type error = %v, want exact type failure", err)
}
}
func TestArtifactEvidenceRegistryRejectsInvalidRegistration(t *testing.T) {
registry := NewArtifactEvidenceRegistry()
if err := RegisterArtifactEvidence[codecNotes](nil, "test/notes", func(codecNotes) []source.SourceRef { return nil }); err == nil || !strings.Contains(err.Error(), "must not be nil") {
t.Fatalf("nil registry error = %v, want failure", err)
}
if err := RegisterArtifactEvidence(registry, " ", func(codecNotes) []source.SourceRef { return nil }); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("blank kind error = %v, want failure", err)
}
if err := RegisterArtifactEvidence(registry, "test/notes", ArtifactEvidenceProjector[codecNotes](nil)); err == nil || !strings.Contains(err.Error(), "must not be nil") {
t.Fatalf("nil projector error = %v, want failure", err)
}
if err := RegisterArtifactEvidence(registry, "test/notes", func(codecNotes) []source.SourceRef { return nil }); err != nil {
t.Fatal(err)
}
if err := RegisterArtifactEvidence(registry, "test/notes", func(codecNotes) []source.SourceRef { return nil }); err == nil || !strings.Contains(err.Error(), "already registered") {
t.Fatalf("duplicate kind error = %v, want failure", err)
}
}

View File

@@ -0,0 +1,20 @@
package pipeline
// EvidenceContextPolicy controls optional source-context publication by an
// output encoder.
type EvidenceContextPolicy struct {
Enabled bool
WindowUnits int
LaneIDs []string
}
// EvidenceContextPolicyProvider is implemented by output encoders that opt in
// to evidence-context publication.
type EvidenceContextPolicyProvider interface {
EvidenceContextPolicy() EvidenceContextPolicy
}
func cloneEvidenceContextPolicy(policy EvidenceContextPolicy) EvidenceContextPolicy {
policy.LaneIDs = append([]string(nil), policy.LaneIDs...)
return policy
}

View File

@@ -0,0 +1,98 @@
package pipeline
import (
"context"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
type testEvidenceOutput struct {
policy EvidenceContextPolicy
}
func (output testEvidenceOutput) Key() string { return "output" }
func (output testEvidenceOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
return contracts.OutputResult{}, nil
}
func (output testEvidenceOutput) EvidenceContextPolicy() EvidenceContextPolicy {
return cloneEvidenceContextPolicy(output.policy)
}
func TestPrepareEvidencePlanSelectsActiveLanesAndOwnsPolicy(t *testing.T) {
registries, _ := constructionRegistries(t, nil, nil)
registries.ArtifactEvidence = NewArtifactEvidenceRegistry()
registerTestEvidenceOutput(t, &registries, EvidenceContextPolicy{Enabled: true, WindowUnits: 2, LaneIDs: []string{"artifact", "inactive"}})
if err := RegisterArtifactEvidence(registries.ArtifactEvidence, "test/notes", func(codecNotes) []source.SourceRef { return nil }); err != nil {
t.Fatal(err)
}
profile := constructionProfile()
profile.Output.Options = map[string]any{"known": true}
resolved, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
if err != nil {
t.Fatal(err)
}
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
if prepared.evidencePlan == nil || !reflect.DeepEqual(prepared.evidencePlan.policy.LaneIDs, []string{"artifact", "inactive"}) {
t.Fatalf("evidence plan = %#v, want complete configured policy", prepared.evidencePlan)
}
if got := prepared.evidencePlan.lanes; len(got) != 1 || got[0].laneID != "artifact" {
t.Fatalf("active evidence lanes = %#v, want artifact only", got)
}
prepared.evidencePlan.policy.LaneIDs[0] = "mutated"
if policy := prepared.output.(EvidenceContextPolicyProvider).EvidenceContextPolicy(); policy.LaneIDs[0] != "artifact" {
t.Fatalf("prepared plan mutated provider policy: %#v", policy)
}
}
func TestPrepareEvidencePlanRejectsMissingAndMismatchedCapabilities(t *testing.T) {
for _, test := range []struct {
name string
configure func(*Registries)
want string
}{
{name: "missing registry", configure: func(registries *Registries) { registries.ArtifactEvidence = nil }, want: "artifact evidence registry"},
{name: "unsupported kind", configure: func(registries *Registries) {}, want: "artifact evidence \"test/notes\" is not registered"},
{name: "mismatched type", configure: func(registries *Registries) {
if err := RegisterArtifactEvidence(registries.ArtifactEvidence, "test/notes", func(codecScore) []source.SourceRef { return nil }); err != nil {
t.Fatal(err)
}
}, want: "requires Go type"},
} {
t.Run(test.name, func(t *testing.T) {
registries, _ := constructionRegistries(t, nil, nil)
registries.ArtifactEvidence = NewArtifactEvidenceRegistry()
registerTestEvidenceOutput(t, &registries, EvidenceContextPolicy{Enabled: true, LaneIDs: []string{"artifact"}})
test.configure(&registries)
profile := constructionProfile()
profile.Output.Options = map[string]any{"known": true}
resolved, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
if err != nil {
t.Fatal(err)
}
_, err = Prepare(resolved, registries, ModuleDependencies{})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Prepare() error = %v, want %q", err, test.want)
}
})
}
}
func registerTestEvidenceOutput(t *testing.T, registries *Registries, policy EvidenceContextPolicy) {
t.Helper()
registry := NewOutputEncoderRegistry()
if err := registry.RegisterBuilderWithSpec(defaultModuleSpec("output", StageOutput), func(options map[string]any) error {
return RejectUnknownOptions(options, "known")
}, func(BuildRequest) (contracts.OutputEncoder, error) {
return testEvidenceOutput{policy: cloneEvidenceContextPolicy(policy)}, nil
}); err != nil {
t.Fatal(err)
}
registries.Outputs = registry
}

View File

@@ -2,7 +2,7 @@ package pipeline
import "fmt"
func validateResolvedOptions(resolved ResolvedPipeline, catalog ModuleCatalog) error {
func validateResolvedOptions(resolved ResolvedPipeline, catalog ModuleCatalog, configuredLaneIDs []string) error {
if err := catalog.Inputs.ValidateOptions(resolved.Input.Module, resolved.Input.Options); err != nil {
return moduleOptionsError(resolved.ID, "", StageInput, resolved.Input.Module, err)
}
@@ -42,6 +42,9 @@ func validateResolvedOptions(resolved ResolvedPipeline, catalog ModuleCatalog) e
if err := catalog.Outputs.ValidateOptions(resolved.Output.Module, resolved.Output.Options); err != nil {
return moduleOptionsError(resolved.ID, "", StageOutput, resolved.Output.Module, err)
}
if err := catalog.Outputs.ValidateProfileOptions(resolved.Output.Module, OutputProfileOptionContext{LaneIDs: configuredLaneIDs}, resolved.Output.Options); err != nil {
return moduleOptionsError(resolved.ID, "", StageOutput, resolved.Output.Module, err)
}
return nil
}

View File

@@ -10,17 +10,25 @@ import (
type OutputEncoderConstructor func() (contracts.OutputEncoder, error)
type OutputEncoderBuilder func(BuildRequest) (contracts.OutputEncoder, error)
type OutputProfileOptionContext struct {
LaneIDs []string
}
type OutputProfileOptionValidator func(OutputProfileOptionContext, map[string]any) error
type OutputEncoderRegistry struct {
builders map[string]OutputEncoderBuilder
optionValidators map[string]OptionValidator
specs map[string]ModuleSpec
builders map[string]OutputEncoderBuilder
optionValidators map[string]OptionValidator
profileValidators map[string]OutputProfileOptionValidator
specs map[string]ModuleSpec
}
func NewOutputEncoderRegistry() *OutputEncoderRegistry {
return &OutputEncoderRegistry{
builders: make(map[string]OutputEncoderBuilder),
optionValidators: make(map[string]OptionValidator),
specs: make(map[string]ModuleSpec),
builders: make(map[string]OutputEncoderBuilder),
optionValidators: make(map[string]OptionValidator),
profileValidators: make(map[string]OutputProfileOptionValidator),
specs: make(map[string]ModuleSpec),
}
}
@@ -38,6 +46,12 @@ func (r *OutputEncoderRegistry) RegisterWithSpec(spec ModuleSpec, constructor Ou
}
func (r *OutputEncoderRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder OutputEncoderBuilder) error {
return r.RegisterBuilderWithProfileValidation(spec, validateOptions, nil, builder)
}
// RegisterBuilderWithProfileValidation registers an output builder with an
// optional validator that can inspect all configured lane identities.
func (r *OutputEncoderRegistry) RegisterBuilderWithProfileValidation(spec ModuleSpec, validateOptions OptionValidator, validateProfile OutputProfileOptionValidator, builder OutputEncoderBuilder) error {
if r == nil {
return fmt.Errorf("output encoder registry must not be nil")
}
@@ -62,11 +76,15 @@ func (r *OutputEncoderRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validat
if r.optionValidators == nil {
r.optionValidators = make(map[string]OptionValidator)
}
if r.profileValidators == nil {
r.profileValidators = make(map[string]OutputProfileOptionValidator)
}
if r.specs == nil {
r.specs = make(map[string]ModuleSpec)
}
r.builders[normalizedSpec.Key] = builder
r.optionValidators[normalizedSpec.Key] = validateOptions
r.profileValidators[normalizedSpec.Key] = validateProfile
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil
}
@@ -116,6 +134,21 @@ func (r *OutputEncoderRegistry) ValidateOptions(key string, options map[string]a
return validateRegisteredOptions(validator, options)
}
func (r *OutputEncoderRegistry) ValidateProfileOptions(key string, context OutputProfileOptionContext, options map[string]any) error {
if r == nil {
return fmt.Errorf("output encoder registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
validator, ok := r.profileValidators[normalizedKey]
if !ok {
return fmt.Errorf("output encoder %q is not registered", normalizedKey)
}
if validator == nil {
return nil
}
return validator(OutputProfileOptionContext{LaneIDs: append([]string(nil), context.LaneIDs...)}, cloneOptions(options))
}
func (r *OutputEncoderRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil {
return ModuleSpec{}, false

View File

@@ -1,6 +1,7 @@
package pipeline
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -56,3 +57,33 @@ func TestOutputEncoderRegistryBehavior(t *testing.T) {
},
})
}
func TestOutputProfileValidationReceivesOwnedOptionsAndLaneIDs(t *testing.T) {
registry := NewOutputEncoderRegistry()
if err := registry.RegisterBuilderWithProfileValidation(defaultModuleSpec("profile-output", StageOutput), func(options map[string]any) error {
options["nested"].(map[string]any)["value"] = "changed"
return nil
}, func(context OutputProfileOptionContext, options map[string]any) error {
context.LaneIDs[0] = "changed"
options["nested"].(map[string]any)["value"] = "changed-again"
return nil
}, func(BuildRequest) (contracts.OutputEncoder, error) {
return registryOutputEncoder{key: "profile-output"}, nil
}); err != nil {
t.Fatal(err)
}
options := map[string]any{"nested": map[string]any{"value": "original"}}
if err := registry.ValidateOptions("profile-output", options); err != nil {
t.Fatal(err)
}
context := OutputProfileOptionContext{LaneIDs: []string{"artifact"}}
if err := registry.ValidateProfileOptions("profile-output", context, options); err != nil {
t.Fatal(err)
}
if got := options["nested"].(map[string]any)["value"]; got != "original" {
t.Fatalf("options mutated by validator: %q", got)
}
if want := []string{"artifact"}; !reflect.DeepEqual(context.LaneIDs, want) {
t.Fatalf("lane ids mutated by validator: %#v, want %#v", context.LaneIDs, want)
}
}

View File

@@ -5,6 +5,7 @@ import (
"reflect"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
@@ -24,6 +25,7 @@ type PreparedPipeline struct {
chunkValidators preparedValidatorChain
output contracts.OutputEncoder
artifactCodecs *ArtifactCodecRegistry
evidencePlan *preparedEvidencePlan
checkpointFingerprints []CheckpointFingerprint
}
@@ -55,6 +57,17 @@ type preparedTypedLane struct {
codec artifactCodecEntry
}
type preparedEvidencePlan struct {
policy EvidenceContextPolicy
lanes []preparedEvidenceLane
}
type preparedEvidenceLane struct {
laneID string
kind contracts.ArtifactKind
project func(any) ([]source.SourceRef, error)
}
type preparedValidatorChain struct {
resolved ResolvedValidatorChain
validators []preparedValidator
@@ -129,6 +142,10 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend
return nil, constructionError(stable.ID, "", StageOutput, stable.Output.Module, "", err)
}
prepared.output = output
prepared.evidencePlan, err = prepareEvidencePlan(stable, registries, output)
if err != nil {
return nil, err
}
prepared.checkpointFingerprints, err = collectPreparedCheckpointFingerprints(prepared)
if err != nil {
return nil, err
@@ -136,6 +153,52 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend
return prepared, nil
}
func prepareEvidencePlan(resolved ResolvedPipeline, registries Registries, output contracts.OutputEncoder) (*preparedEvidencePlan, error) {
provider, ok := output.(EvidenceContextPolicyProvider)
if !ok {
return nil, nil
}
policy := cloneEvidenceContextPolicy(provider.EvidenceContextPolicy())
if !policy.Enabled {
return nil, nil
}
if policy.WindowUnits < 0 {
return nil, constructionError(resolved.ID, "", StageOutput, resolved.Output.Module, "", fmt.Errorf("evidence window units must not be negative"))
}
lanes, err := normalizeEvidenceLaneIDs(policy.LaneIDs)
if err != nil {
return nil, constructionError(resolved.ID, "", StageOutput, resolved.Output.Module, "", err)
}
policy.LaneIDs = lanes
active := make(map[string]ResolvedArtifactLane)
for _, lane := range resolved.AllArtifactLanes() {
active[lane.ID] = lane
}
plan := &preparedEvidencePlan{policy: cloneEvidenceContextPolicy(policy)}
for _, laneID := range policy.LaneIDs {
lane, ok := active[laneID]
if !ok {
continue
}
if registries.ArtifactEvidence == nil {
return nil, constructionError(resolved.ID, laneID, StageOutput, resolved.Output.Module, "", fmt.Errorf("artifact evidence registry must not be nil for active evidence lane"))
}
evidence, _, evidenceErr := registries.ArtifactEvidence.entry(lane.ArtifactKind)
if evidenceErr != nil {
return nil, constructionError(resolved.ID, laneID, StageOutput, resolved.Output.Module, "", evidenceErr)
}
codecType, ok := registries.ArtifactCodecs.valueType(lane.ArtifactKind)
if !ok {
return nil, constructionError(resolved.ID, laneID, StageOutput, resolved.Output.Module, "", fmt.Errorf("artifact codec %q has no Go type", lane.ArtifactKind))
}
if evidence.valueType != codecType {
return nil, constructionError(resolved.ID, laneID, StageOutput, resolved.Output.Module, "", fmt.Errorf("artifact evidence kind %q requires Go type %s, but active artifact codec uses %s", lane.ArtifactKind, typeName(evidence.valueType), typeName(codecType)))
}
plan.lanes = append(plan.lanes, preparedEvidenceLane{laneID: laneID, kind: lane.ArtifactKind, project: evidence.project})
}
return plan, nil
}
func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registries Registries, deps ModuleDependencies) (preparedLaneExecutor, error) {
executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)}
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest {
@@ -303,7 +366,7 @@ func constructionError(pipelineID, laneID string, stage ModuleStage, moduleKey,
func (registries Registries) catalog() ModuleCatalog {
return ModuleCatalog{
Inputs: registries.Inputs, Chunkers: registries.Chunkers, ArtifactCodecs: registries.ArtifactCodecs,
Inputs: registries.Inputs, Chunkers: registries.Chunkers, ArtifactCodecs: registries.ArtifactCodecs, ArtifactEvidence: registries.ArtifactEvidence,
Extractors: registries.Extractors, Mergers: registries.Mergers, Normalizers: registries.Normalizers,
Validators: registries.Validators, ValidatorChains: registries.ValidatorChains, Outputs: registries.Outputs,
}

View File

@@ -213,15 +213,16 @@ func (resolved ResolvedPipeline) AllArtifactLanes() []ResolvedArtifactLane {
}
type ModuleCatalog struct {
Inputs *InputAdapterRegistry
Chunkers *ChunkerRegistry
ArtifactCodecs *ArtifactCodecRegistry
Extractors *ExtractorRegistry
Mergers *MergerRegistry
Normalizers *NormalizerRegistry
Validators *ValidatorRegistry
ValidatorChains *ValidatorChainRegistry
Outputs *OutputEncoderRegistry
Inputs *InputAdapterRegistry
Chunkers *ChunkerRegistry
ArtifactCodecs *ArtifactCodecRegistry
ArtifactEvidence *ArtifactEvidenceRegistry
Extractors *ExtractorRegistry
Mergers *MergerRegistry
Normalizers *NormalizerRegistry
Validators *ValidatorRegistry
ValidatorChains *ValidatorChainRegistry
Outputs *OutputEncoderRegistry
}
func Binding(module string) ModuleBinding {
@@ -251,6 +252,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
if len(steps) == 0 {
return ResolvedPipeline{}, fmt.Errorf("pipeline %q must declare at least one artifact lane", pipelineID)
}
configuredLaneIDs, err := configuredProfileLaneIDs(pipelineID, steps)
if err != nil {
return ResolvedPipeline{}, err
}
input := resolveBinding(profile.Input, "")
if input.Module == "" {
@@ -381,7 +386,7 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
if missing, ok := outputCapabilities.missing(outputSpec.Requires); ok {
return ResolvedPipeline{}, capabilityError(pipelineID, "", StageOutput, resolved.Output.Module, missing)
}
if err := validateResolvedOptions(resolved, catalog); err != nil {
if err := validateResolvedOptions(resolved, catalog, configuredLaneIDs); err != nil {
return ResolvedPipeline{}, err
}
@@ -393,6 +398,29 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
return resolved, nil
}
// configuredProfileLaneIDs validates the complete configured lane identity
// set before invocation-level selection removes legacy-profile lanes.
func configuredProfileLaneIDs(pipelineID string, steps []PipelineStepProfile) ([]string, error) {
seen := make(map[string]string)
var laneIDs []string
for _, step := range steps {
_, ids, err := selectedArtifactLanes(pipelineID, step.Artifacts, ResolveOptions{})
if err != nil {
return nil, err
}
stepID := strings.TrimSpace(step.ID)
for _, laneID := range ids {
if previous, ok := seen[laneID]; ok {
return nil, fmt.Errorf("pipeline %q artifact lane id %q is duplicated across steps %q and %q", pipelineID, laneID, previous, stepID)
}
seen[laneID] = stepID
laneIDs = append(laneIDs, laneID)
}
}
sort.Strings(laneIDs)
return laneIDs, nil
}
func resolveArtifactLane(
pipelineID string,
stepID string,
@@ -1190,11 +1218,32 @@ func cloneOptions(options map[string]any) map[string]any {
copied := make(map[string]any, len(options))
for key, value := range options {
copied[key] = value
copied[key] = cloneOptionValue(value)
}
return copied
}
func cloneOptionValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneOptions(typed)
case []any:
out := make([]any, len(typed))
for i := range typed {
out[i] = cloneOptionValue(typed[i])
}
return out
case []string:
return append([]string(nil), typed...)
case []byte:
return append([]byte(nil), typed...)
case json.RawMessage:
return append(json.RawMessage(nil), typed...)
default:
return value
}
}
func normalizeReferenceMap(values map[string]ReferenceSource) map[string]ReferenceSource {
if len(values) == 0 {
return nil

View File

@@ -21,15 +21,16 @@ import (
)
type Registries struct {
Inputs *InputAdapterRegistry
Chunkers *ChunkerRegistry
ArtifactCodecs *ArtifactCodecRegistry
Extractors *ExtractorRegistry
Mergers *MergerRegistry
Normalizers *NormalizerRegistry
Validators *ValidatorRegistry
ValidatorChains *ValidatorChainRegistry
Outputs *OutputEncoderRegistry
Inputs *InputAdapterRegistry
Chunkers *ChunkerRegistry
ArtifactCodecs *ArtifactCodecRegistry
ArtifactEvidence *ArtifactEvidenceRegistry
Extractors *ExtractorRegistry
Mergers *MergerRegistry
Normalizers *NormalizerRegistry
Validators *ValidatorRegistry
ValidatorChains *ValidatorChainRegistry
Outputs *OutputEncoderRegistry
}
type Runner struct{}