Centralize builder request cloning

This commit is contained in:
2026-08-09 01:27:28 +00:00
parent 8e669a1f14
commit ffc179c822
5 changed files with 198 additions and 36 deletions

View File

@@ -51,11 +51,12 @@ until its producing step completes.
Preparation is the construction boundary. It validates the resolved shape and
registry set, clones the resolved data, then constructs the input adapter,
chunker, stage-local validators, every typed lane, and output encoder with
cloned options, references, and shared dependencies. It also collects stable
checkpoint fingerprints. Missing registrations, incompatible typed entries,
nil implementations, and constructor failures are reported before source
parsing or any stage operation begins.
chunker, stage-local validators, every typed lane, and output encoder. Each
registered builder receives its own cloned build request immediately before its
module-owned code runs. Preparation also collects stable checkpoint
fingerprints. Missing registrations, incompatible typed entries, nil
implementations, and constructor failures are reported before source parsing
or any stage operation begins.
An output encoder can opt into source-evidence publication through its output
policy. Preparation keeps the configured lane allowlist and active lanes

View File

@@ -305,6 +305,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
if name != "extract" {
return
}
request.Options["nested"].(map[string]any)["value"] = "mutated by extractor builder"
slot := request.References.Slots["extract"]
slot.Items[0].Content = []byte("mutated by extractor builder")
request.References.Slots["extract"] = slot
@@ -318,6 +319,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = constructionReferenceSet("extract", "extract reference")
resolved.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet = constructionReferenceSet("merge", "merge reference")
resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet = constructionReferenceSet("normalize", "normalize reference")
resolved.Steps[0].ArtifactLanes[0].Extract.Options = constructionBuildRequest().Options
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
if err != nil {
@@ -339,6 +341,15 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
if got := constructionReferenceContent(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
t.Fatalf("resolved extract references = %q, want original content", got)
}
if got := constructionReferenceContent(prepared.resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
t.Fatalf("prepared extract references = %q, want original content", got)
}
if got := resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
t.Fatalf("resolved extract options = %#v, want original nested value", got)
}
if got := prepared.resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
t.Fatalf("prepared extract options = %#v, want original nested value", got)
}
_, err = prepared.Steps[0].lanes[0].typed.extract(context.Background(), prepared.Steps[0].lanes[0].typed.extractor, contracts.TypedExtractionRequest{
References: CloneReferenceSet(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet),
@@ -354,6 +365,163 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
}
}
func TestRegisteredBuildersReceiveIndependentBuildRequests(t *testing.T) {
request := constructionBuildRequest()
probe := buildRequestMutationProbe{t: t, want: cloneBuildRequest(request)}
inputs := NewInputAdapterRegistry()
if err := inputs.RegisterBuilderWithSpec(testModuleSpec("input", StageInput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.InputAdapter, error) {
probe.observe(request)
return &constructionInput{key: "input"}, nil
}); err != nil {
t.Fatal(err)
}
chunkers := NewChunkerRegistry()
if err := chunkers.RegisterBuilderWithSpec(testModuleSpec("chunk", StageChunk), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Chunker, error) {
probe.observe(request)
return &typedTestChunker{key: "chunk"}, nil
}); err != nil {
t.Fatal(err)
}
outputs := NewOutputEncoderRegistry()
if err := outputs.RegisterBuilderWithSpec(testModuleSpec("output", StageOutput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.OutputEncoder, error) {
probe.observe(request)
return &typedTestOutput{key: "output"}, nil
}); err != nil {
t.Fatal(err)
}
extractors := NewExtractorRegistry()
extractSpec := testModuleSpec("extract", StageExtract)
extractSpec.ArtifactKind = "test/notes"
if err := RegisterExtractorBuilder(extractors, extractSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Extractor[codecNotes], error) {
probe.observe(request)
return typedTestExtractor[codecNotes]{key: "extract"}, nil
}); err != nil {
t.Fatal(err)
}
mergers := NewMergerRegistry()
mergeSpec := testModuleSpec("merge", StageMerge)
mergeSpec.ArtifactKind = "test/notes"
if err := RegisterMergerBuilder(mergers, mergeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Merger[codecNotes], error) {
probe.observe(request)
return typedTestMerger[codecNotes]{key: "merge"}, nil
}); err != nil {
t.Fatal(err)
}
normalizers := NewNormalizerRegistry()
normalizeSpec := testModuleSpec("normalize", StageNormalize)
normalizeSpec.ArtifactKind = "test/notes"
if err := RegisterNormalizerBuilder(normalizers, normalizeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Normalizer[codecNotes], error) {
probe.observe(request)
return typedTestNormalizer[codecNotes]{key: "normalize"}, nil
}); err != nil {
t.Fatal(err)
}
validators := NewValidatorRegistry()
if err := RegisterChunkValidatorBuilder(validators, ValidatorSpec{Key: "chunk-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.ChunkValidator, error) {
probe.observe(request)
return typedTestChunkValidator{key: "chunk-validator"}, nil
}); err != nil {
t.Fatal(err)
}
if err := RegisterTypedValidatorBuilder(validators, "test/notes", ValidatorSpec{Key: "typed-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.TypedValidator[codecNotes], error) {
probe.observe(request)
return typedTestValidator[codecNotes]{key: "typed-validator"}, nil
}); err != nil {
t.Fatal(err)
}
if err := RegisterSerializedValidatorBuilder(validators, SerializedValidatorSpec{ValidatorSpec: ValidatorSpec{Key: "serialized-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, SupportsArtifacts: true}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.SerializedValidator, error) {
probe.observe(request)
return typedTestSerializedValidator{key: "serialized-validator"}, nil
}); err != nil {
t.Fatal(err)
}
builders := []struct {
name string
call func() error
}{
{name: "input", call: func() error { _, err := inputs.BuildWithRequest("input", request); return err }},
{name: "chunk", call: func() error { _, err := chunkers.BuildWithRequest("chunk", request); return err }},
{name: "output", call: func() error { _, err := outputs.BuildWithRequest("output", request); return err }},
{name: "extract", call: func() error {
entry, _ := extractors.typedEntry("extract")
_, err := entry.builder(request)
return err
}},
{name: "merge", call: func() error {
entry, _ := mergers.typedEntry("merge", "test/notes")
_, err := entry.builder(request)
return err
}},
{name: "normalize", call: func() error {
entry, _ := normalizers.typedEntry("normalize", "test/notes")
_, err := entry.builder(request)
return err
}},
{name: "chunk validator", call: func() error {
entry, _ := validators.chunkEntry("chunk-validator")
_, err := entry.builder(request)
return err
}},
{name: "typed validator", call: func() error {
entry, _ := validators.typedEntry("typed-validator", "test/notes")
_, err := entry.builder(request)
return err
}},
{name: "serialized validator", call: func() error {
entry, _ := validators.serializedEntry("serialized-validator")
_, err := entry.builder(request)
return err
}},
}
for _, builder := range builders {
t.Run(builder.name, func(t *testing.T) {
if err := builder.call(); err != nil {
t.Fatal(err)
}
})
}
if !reflect.DeepEqual(request, probe.want) {
t.Fatalf("build request = %#v, want unchanged %#v", request, probe.want)
}
}
type buildRequestMutationProbe struct {
t *testing.T
want BuildRequest
}
func (probe buildRequestMutationProbe) observe(request BuildRequest) {
probe.t.Helper()
if !reflect.DeepEqual(request, probe.want) {
probe.t.Fatalf("builder request = %#v, want independently owned %#v", request, probe.want)
}
options := request.Options["nested"].(map[string]any)
options["value"] = "mutated"
request.Options["items"].([]any)[0].(map[string]any)["value"] = "mutated"
request.Options["bytes"].([]byte)[0] = 'x'
slot := request.References.Slots["reference"]
slot.Items[0].Content[0] = 'x'
slot.Items = append(slot.Items, contracts.ReferenceItem{SlotName: "reference", Content: []byte("extra")})
request.References.Slots["reference"] = slot
delete(request.References.Slots, "unused")
}
func constructionBuildRequest() BuildRequest {
return BuildRequest{
Options: map[string]any{
"nested": map[string]any{"value": "original"},
"items": []any{map[string]any{"value": "original"}},
"bytes": []byte("original"),
},
References: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"reference": {Slot: contracts.ReferenceSlot{Name: "reference"}, Items: []contracts.ReferenceItem{{SlotName: "reference", Content: []byte("original")}}},
"unused": {Slot: contracts.ReferenceSlot{Name: "unused"}},
}},
}
}
func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
tests := []struct {
name string

View File

@@ -100,7 +100,7 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend
artifactCodecs: registries.ArtifactCodecs,
}
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest {
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references}
return BuildRequest{Dependencies: deps, Options: binding.Options, References: references}
}
input, err := registries.Inputs.BuildWithRequest(stable.Input.Module, request(stable.Input, contracts.ReferenceSet{}))
@@ -202,7 +202,7 @@ func prepareEvidencePlan(resolved ResolvedPipeline, registries Registries, outpu
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 {
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references}
return BuildRequest{Dependencies: deps, Options: binding.Options, References: references}
}
extractEntry, ok := registries.Extractors.typedEntry(lane.Extract.Module)
if !ok {
@@ -259,7 +259,7 @@ func prepareValidatorChain(pipeline ResolvedPipeline, registries Registries, dep
resolved := resolvedValidatorChain(stage, laneID, moduleKey, pipeline.ValidatorChains)
prepared := preparedValidatorChain{resolved: resolved}
for _, validator := range resolved.Validators {
request := BuildRequest{Dependencies: deps, Options: cloneOptions(validator.Binding.Options), References: references}
request := BuildRequest{Dependencies: deps, Options: validator.Binding.Options, References: references}
built, err := buildPreparedValidator(registries.Validators, validator, request)
if err != nil {
return preparedValidatorChain{}, constructionError(pipeline.ID, laneID, stage, moduleKey, validator.Binding.Module, err)
@@ -280,7 +280,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
if !ok {
return preparedValidator{}, fmt.Errorf("typed construction entry is not registered")
}
implementation, err = entry.builder(cloneBuildRequest(request))
implementation, err = entry.builder(request)
prepared.typed = implementation
prepared.typedValidate = entry.validate
case ValidatorTargetChunk:
@@ -288,14 +288,14 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
if !ok {
return preparedValidator{}, fmt.Errorf("chunk construction entry is not registered")
}
prepared.chunk, err = entry.builder(cloneBuildRequest(request))
prepared.chunk, err = entry.builder(request)
implementation = prepared.chunk
case ValidatorTargetSerialized:
entry, ok := registry.serializedEntry(key)
if !ok {
return preparedValidator{}, fmt.Errorf("serialized construction entry is not registered")
}
prepared.serialized, err = entry.builder(cloneBuildRequest(request))
prepared.serialized, err = entry.builder(request)
implementation = prepared.serialized
default:
return preparedValidator{}, fmt.Errorf("validator construction target %q is not supported", resolved.Target)
@@ -323,7 +323,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
}
func buildErasedModule(builder func(BuildRequest) (any, error), request BuildRequest, key, kind string) (any, error) {
implementation, err := builder(cloneBuildRequest(request))
implementation, err := builder(request)
if err != nil {
return nil, err
}

View File

@@ -150,7 +150,13 @@ func RegisterChunkValidatorBuilder(registry *ValidatorRegistry, spec ValidatorSp
if registry.chunkEntries == nil {
registry.chunkEntries = make(map[string]chunkValidatorEntry)
}
registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{spec: normalizedSpec, validateOptions: validateOptions, builder: builder}
registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{
spec: normalizedSpec,
validateOptions: validateOptions,
builder: func(request BuildRequest) (contracts.ChunkValidator, error) {
return builder(cloneBuildRequest(request))
},
}
return nil
}
@@ -187,7 +193,13 @@ func RegisterSerializedValidatorBuilder(registry *ValidatorRegistry, spec Serial
if registry.serializedEntries == nil {
registry.serializedEntries = make(map[string]serializedValidatorEntry)
}
registry.serializedEntries[spec.Key] = serializedValidatorEntry{spec: spec, validateOptions: validateOptions, builder: builder}
registry.serializedEntries[spec.Key] = serializedValidatorEntry{
spec: spec,
validateOptions: validateOptions,
builder: func(request BuildRequest) (contracts.SerializedValidator, error) {
return builder(cloneBuildRequest(request))
},
}
return nil
}

View File

@@ -50,16 +50,15 @@ func (c EffectiveCatalog) Lookup(name string) (string, bool) {
}
// ResolveEffectiveCatalog loads the embedded SRD catalog and applies the
// optional spell-catalog overlay found in the cloned reference set. It does
// not resolve paths or perform filesystem access.
// optional spell-catalog overlay found in its spell_catalog reference slot. It
// does not resolve paths or perform filesystem access.
func ResolveEffectiveCatalog(references contracts.ReferenceSet) (EffectiveCatalog, error) {
base, err := LoadSRD5E2014()
if err != nil {
return EffectiveCatalog{}, err
}
cloned := cloneReferenceSet(references)
slot, ok := cloned.Slots[SpellCatalogReferenceSlot]
slot, ok := references.Slots[SpellCatalogReferenceSlot]
if !ok || len(slot.Items) == 0 {
return composeEffectiveCatalog(base, nil)
}
@@ -407,21 +406,3 @@ func cloneStringMap(values map[string]string) map[string]string {
}
return out
}
func cloneReferenceSet(in contracts.ReferenceSet) contracts.ReferenceSet {
if len(in.Slots) == 0 {
return contracts.ReferenceSet{}
}
out := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(in.Slots))}
for name, slot := range in.Slots {
slot.Slot.AcceptedMediaTypes = append([]string(nil), slot.Slot.AcceptedMediaTypes...)
items := make([]contracts.ReferenceItem, len(slot.Items))
for index, item := range slot.Items {
item.Content = append([]byte(nil), item.Content...)
items[index] = item
}
slot.Items = items
out.Slots[name] = slot
}
return out
}