From 53601e68ed3fde5754813e6868467b8e6e841051 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 14 Oct 2026 13:56:22 +0000 Subject: [PATCH] Expose input destinations through the public facade --- conversion.go | 4 +- docs/config.md | 27 +++ docs/roadmap/implementation.md | 24 ++- facade_destination_test.go | 343 +++++++++++++++++++++++++++++++++ layered.go | 18 +- records.go | 11 +- records_test.go | 18 ++ 7 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 facade_destination_test.go diff --git a/conversion.go b/conversion.go index b37baf9..718146b 100644 --- a/conversion.go +++ b/conversion.go @@ -43,10 +43,10 @@ func toEngineLocations(values []Location) []engine.Location { return result } func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo { - return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)} + return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)} } func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { - return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)} + return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)} } func fromEngineOrigin(value engine.Origin) Origin { result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)} diff --git a/docs/config.md b/docs/config.md index c2b79dc..f5f17f8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -149,6 +149,33 @@ have index zero. Explicit empty groups and `NoSource` consume no source ID. Byte, source, node, and glob-entry budgets are shared across the load, while the existing per-source and import-depth bounds still apply. +#### Input destinations + +`Input.Destination` places the source's application mapping beneath a path +relative to the configuration root. Its zero or empty value means root, so +existing inputs keep their behavior. Main inputs and underlay inputs use the +same destination model. Destination segments are literal mapping keys: dots, +slashes, and numeric-looking text are not split or interpreted as sequence +indexes. Explicit index segments and paths that introduce the reserved +`configkit` or mapping-valued `extends` structure are invalid input options and +produce a structured `LoadError` before source acquisition. + +Source-root `configkit.imports` directives are processed before placement, and +each imported application contribution inherits its input's destination, +including transitive imports. A valid destination on `NoSource()` contributes +nothing; an actual empty mapping materializes its destination containers. Once +placed, contributions follow the same strict composition within a tier and +recursive precedence behavior described above. A selected overlay and CLI +overrides remain rooted at the application root and are not moved beneath the +main input's destination. + +`SourceInfo.Destination` reports the placement for each acquired input source, +including imports. Placement does not change source IDs, acquisition identity, +physical source locations, or passive resource contexts. Introduced ancestor +mappings use the contributing document's root location as a source fallback; +authored content retains its original source coordinates. Returned source +records are detached copies. + The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input) shows a main host file, two underlay tiers, embedded defaults, a mapping source, and explicit resource access without a second configuration pass. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c435579..9b7a6e0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -423,7 +423,7 @@ pass. ## Stage 10 — Expose the root-owned facade and public acceptance tests -Status: planned. Scope: the two public fields and complete consumer behavior. +Status: complete. Scope: the two public fields and complete consumer behavior. 1. Add `Input.Destination Path` and `SourceInfo.Destination Path` to root types. Extend `toEngineInput`, `fromEngineSourceInfo`, `toEngineSourceInfo`, and all @@ -443,9 +443,25 @@ Status: planned. Scope: the two public fields and complete consumer behavior. matrices; public tests demonstrate supported integration rather than every private branch. Confirm failed loads expose detached source destinations. -Validate: `go test . -run 'Destination|Facade|PublicFacadeBoundary|DependencyImportBoundary'`, -`go test -race ./...`, and everyday check. Done: the requested workflow is usable -entirely through ConfigKit's supported import path and documented surface. +Validated with `go test . -run 'Destination|FacadeLayered|PublicFacadeBoundary|DependencyImportBoundary' -count=1`, +`go test -race ./...`, and `./scripts/check.sh` (go1.26.8 linux/amd64); all +passed. + +`TestFacadeInputDestinationsAcrossSourceKinds` exercises host, native +filesystem, bytes, reader, and mapping inputs with literal escaped and +numeric-looking mapping keys. `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths` +covers omitted/empty root placement, empty mapping presence, absent `NoSource`, +invalid index/reserved destinations, and reader preflight. `TestFacadeImportedSourcesRetainInputDestination` +checks public transitive import placement. `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` +ties mounted main/underlay values to metadata, both source destinations, callback +source context, relative-resource access during and after loading, history, safe +effective/explanation rendering, and bilateral diff redaction; it also mutates +returned records and confirms later queries and comparisons remain unchanged. +`TestFacadeFailedLoadExposesDetachedSourceDestination` verifies failure records, +and `TestSourceInfoDestinationConversionsAndCopies` covers root/engine source +record conversion. Existing public-boundary tests pass with the new root-owned +fields. Done: destination selection and inspection are available through the +supported ConfigKit import path. ## Stage 11 — Add maintained simple and collection-style consumer examples diff --git a/facade_destination_test.go b/facade_destination_test.go new file mode 100644 index 0000000..0ced938 --- /dev/null +++ b/facade_destination_test.go @@ -0,0 +1,343 @@ +package configkit_test + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) { + directory := t.TempDir() + hostPath := filepath.Join(directory, "host.yml") + if err := os.WriteFile(hostPath, []byte("value: host\n"), 0o600); err != nil { + t.Fatal(err) + } + native := configkit.NewFilesystem("native", fstest.MapFS{ + "main.yml": &fstest.MapFile{Data: []byte("value: native\n")}, + }) + const literalValuePath = "/collections/weather.reporter/profiles/a~1b/~0/0" + destination := configkit.Keys("collections", "weather.reporter", "profiles", "a/b", "~", "0") + for _, test := range []struct { + name string + source configkit.Source + want string + kind configkit.SourceKind + }{ + {name: "host file", source: configkit.File(hostPath), want: "host", kind: configkit.SourceKindFile}, + {name: "native filesystem", source: native.File("main.yml"), want: "native", kind: configkit.SourceKindFilesystem}, + {name: "bytes", source: configkit.Bytes("bytes", []byte("value: bytes\n")), want: "bytes", kind: configkit.SourceKindBytes}, + {name: "reader", source: configkit.Reader("reader", strings.NewReader("value: reader\n")), want: "reader", kind: configkit.SourceKindReader}, + {name: "mapping", source: configkit.Mapping("mapping", map[string]any{"value": "mapping"}), want: "mapping", kind: configkit.SourceKindMapping}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: destination}) + if err != nil { + t.Fatalf("LoadLayered: %v", err) + } + if got := facadeDestinationValue(t, result.Config, "collections", "weather.reporter", "profiles", "a/b", "~", "0", "value"); got != test.want { + t.Fatalf("placed value = %#v, want %q", got, test.want) + } + sources := result.Sources() + if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].Destination.String() != destination.String() || destination.String() != literalValuePath { + t.Fatalf("source placement = %#v, destination = %q", sources, destination.String()) + } + }) + } +} + +func TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths(t *testing.T) { + for _, test := range []struct { + name string + destination configkit.Path + }{ + {name: "omitted destination"}, + {name: "explicit empty destination", destination: configkit.Path{}}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("root", []byte("value: root\n")), Destination: test.destination}) + if err != nil { + t.Fatalf("root load: %v", err) + } + if result.Config["value"] != "root" || len(result.Sources()) != 1 || result.Sources()[0].Destination.String() != "" { + t.Fatalf("root placement = %#v, sources %#v", result.Config, result.Sources()) + } + }) + } + + emptyPath := configkit.Keys("collections", "empty") + empty, err := configkit.LoadLayered[map[string]any](configkit.Input{ + Source: configkit.Mapping("empty", map[string]any{}), Destination: emptyPath, + }) + if err != nil { + t.Fatalf("empty mapping load: %v", err) + } + if value := facadeDestinationMap(t, empty.Config, "collections")["empty"]; value == nil { + t.Fatal("empty mapping did not materialize its destination") + } + if info, ok := empty.Metadata().Lookup(emptyPath); !ok || !info.Explicit { + t.Fatalf("empty destination metadata = %#v, %v", info, ok) + } + + absentPath := configkit.Keys("collections", "absent") + absent, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource(), Destination: absentPath}) + if err != nil { + t.Fatalf("NoSource with a destination: %v", err) + } + if len(absent.Sources()) != 0 { + t.Fatalf("NoSource created source records: %#v", absent.Sources()) + } + if _, ok := absent.Metadata().Lookup(absentPath); ok { + t.Fatal("NoSource materialized its destination") + } + + reader := &destinationCountingReader{} + for _, test := range []struct { + name string + source configkit.Source + destination configkit.Path + wantReads int + }{ + {name: "index segment", source: configkit.Reader("unread", reader), destination: configkit.Keys("catalog").Index(0)}, + {name: "reserved configkit key", source: configkit.NoSource(), destination: configkit.Keys("configkit")}, + {name: "reserved extends key", source: configkit.NoSource(), destination: configkit.Keys("catalog", "extends")}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: test.destination}) + loadError, ok := err.(*configkit.LoadError) + if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid { + t.Fatalf("invalid destination error = %#v", err) + } + if reader.reads != test.wantReads { + t.Fatalf("reader calls = %d, want %d", reader.reads, test.wantReads) + } + }) + } +} + +func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) { + directory := t.TempDir() + mainPath := filepath.Join(directory, "main.yml") + fragmentPath := filepath.Join(directory, "fragment.yml") + if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: fragment.yml}]}\nmain: present\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fragmentPath, []byte("imported: present\n"), 0o600); err != nil { + t.Fatal(err) + } + destination := configkit.Keys("collections", "weatherreporter") + result, err := configkit.LoadLayered[map[string]any](configkit.Input{ + Source: configkit.File(mainPath), Imports: true, Destination: destination, + }) + if err != nil { + t.Fatalf("mounted import load: %v", err) + } + collection := facadeDestinationMap(t, result.Config, "collections", "weatherreporter") + if collection["main"] != "present" || collection["imported"] != "present" { + t.Fatalf("mounted imported values = %#v", collection) + } + sources := result.Sources() + if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() { + t.Fatalf("mounted import source destinations = %#v", sources) + } + if sources[1].ImportedFrom == nil { + t.Fatalf("imported source lacks its authored directive: %#v", sources[1]) + } +} + +type destinationCountingReader struct{ reads int } + +func (reader *destinationCountingReader) Read([]byte) (int, error) { + reader.reads++ + return 0, io.EOF +} + +func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) { + t.Parallel() + + filesystem := configkit.NewFilesystem("resources", fstest.MapFS{ + "assets/summary.md": &fstest.MapFile{Data: []byte("source-relative summary")}, + }) + context := &configkit.FilesystemContext{Root: ".", BaseDir: "assets", FS: &filesystem} + destination := configkit.Keys("catalog", "entries") + tokenPath := configkit.Keys("catalog", "entries", "item", "token") + titlePath := configkit.Keys("catalog", "entries", "item", "title") + var callbacks struct{ resolve, validate int } + load := func(token string) *configkit.Result[map[string]any] { + t.Helper() + main := configkit.Input{ + Source: configkit.Bytes("main", []byte("item:\n title: main\n resource: summary.md\n token: "+token+"\n")), + Destination: destination, Filesystem: context, + } + underlay := configkit.Input{ + Source: configkit.Bytes("underlay", []byte("item:\n title: lower\n retained: lower-value\n token: LOWER-TOKEN-CANARY\n")), + Destination: destination, + } + result, err := configkit.LoadLayered(main, configkit.LayeredOptions[map[string]any]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, + Definition: configkit.Definition[map[string]any]{ + Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error { + callbacks.resolve++ + resourcePath := configkit.Keys("catalog", "entries", "item", "resource") + input, ok := resolution.InputInfo(resourcePath) + if !ok || len(input.Origin.Locations) != 1 { + return fmt.Errorf("mounted resource input has no single source origin") + } + source, ok := resolution.Source(input.Origin.Locations[0].SourceID) + if !ok || source.Destination.String() != destination.String() { + return fmt.Errorf("resolution source did not retain its destination") + } + resourceContext, ok := resolution.SourceContext(source.ID) + if !ok { + return fmt.Errorf("resolution source context is unavailable") + } + data, err := fs.ReadFile(resourceContext.FS, path.Join(resourceContext.BaseDir, "summary.md")) + if err != nil { + return fmt.Errorf("read source-relative resource: %w", err) + } + entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item") + entry["resource_text"] = string(data) + resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath) + return nil + }, + Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ }, + Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() }, + }, + }) + if err != nil { + t.Fatalf("mounted load: %v", err) + } + return result + } + + first := load("FIRST-TOKEN-CANARY") + second := load("SECOND-TOKEN-CANARY") + if callbacks.resolve != 2 || callbacks.validate != 2 { + t.Fatalf("lifecycle callbacks = %#v", callbacks) + } + for _, result := range []*configkit.Result[map[string]any]{first, second} { + entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, result.Config, "catalog"), "entries"), "item") + if entry["title"] != "main" || entry["retained"] != "lower-value" || entry["resource_text"] != "source-relative summary" { + t.Fatalf("mounted main/underlay configuration = %#v", entry) + } + sources := result.Sources() + if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() { + t.Fatalf("public source destinations = %#v", sources) + } + resource, ok := result.SourceContext(sources[0].ID) + if !ok || resource.BaseDir != "assets" { + t.Fatalf("completed source context = %#v, %v", resource, ok) + } + data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, "summary.md")) + if err != nil || string(data) != "source-relative summary" { + t.Fatalf("completed relative resource = %q, %v", data, err) + } + if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerBase { + t.Fatalf("mounted title metadata = %#v, %v", info, ok) + } + if history := result.Metadata().History(titlePath); len(history) < 2 { + t.Fatalf("mounted title history = %#v", history) + } + if explanation, ok := result.Explain(tokenPath); !ok || !explanation.Effective.Redacted { + t.Fatalf("mounted secret explanation = %#v, %v", explanation, ok) + } + var effective, explanation bytes.Buffer + if err := result.WriteEffective(&effective); err != nil { + t.Fatal(err) + } + if err := result.WriteExplanation(&explanation, tokenPath); err != nil { + t.Fatal(err) + } + output := effective.String() + explanation.String() + for _, canary := range []string{"FIRST-TOKEN-CANARY", "SECOND-TOKEN-CANARY", "LOWER-TOKEN-CANARY"} { + if strings.Contains(output, canary) { + t.Fatalf("public inspection leaked %q: %s", canary, output) + } + } + } + + // Mutating published records and query copies must not alter the retained result. + returnedSources := first.Sources() + returnedSources[0].Destination = configkit.Keys("mutated") + if got := first.Sources()[0].Destination.String(); got != destination.String() { + t.Fatalf("source destination changed after caller mutation: %q", got) + } + info, ok := first.Metadata().Lookup(titlePath) + if !ok { + t.Fatal("mounted metadata lookup failed") + } + info.Origin.Locations[0].SourceID = "mutated" + if fresh, ok := first.Metadata().Lookup(titlePath); !ok || fresh.Origin.Locations[0].SourceID == "mutated" { + t.Fatalf("metadata copy mutation escaped: %#v, %v", fresh, ok) + } + history := first.Metadata().History(titlePath) + if len(history) == 0 { + t.Fatal("mounted history is empty") + } + history[0].Path = configkit.Keys("mutated") + if fresh := first.Metadata().History(titlePath); len(fresh) == 0 || fresh[0].Path.String() != titlePath.String() { + t.Fatalf("history copy mutation escaped: %#v", fresh) + } + + differences, err := configkit.Diff(first, second) + if err != nil { + t.Fatal(err) + } + if len(differences) != 1 || differences[0].Path.String() != tokenPath.String() || + !differences[0].Left.Redacted || !differences[0].Right.Redacted || + strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "TOKEN-CANARY") { + t.Fatalf("public mounted secret diff = %#v", differences) + } + if callbacks.resolve != 2 || callbacks.validate != 2 { + t.Fatalf("inspection reran lifecycle callbacks: %#v", callbacks) + } +} + +func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) { + type configuration struct { + Tenant struct { + Count int `yaml:"count"` + } `yaml:"tenant"` + } + destination := configkit.Keys("tenant") + _, err := configkit.LoadLayered[configuration](configkit.Input{ + Source: configkit.Bytes("invalid", []byte("count: not-an-integer\n")), Destination: destination, + }) + loadError, ok := err.(*configkit.LoadError) + if !ok { + t.Fatalf("failed load error = %T %v", err, err) + } + sources := loadError.Sources() + if len(sources) != 1 || sources[0].Destination.String() != destination.String() { + t.Fatalf("failed source destinations = %#v", sources) + } + sources[0].Destination = configkit.Keys("caller-change") + if fresh := loadError.Sources(); len(fresh) != 1 || fresh[0].Destination.String() != destination.String() { + t.Fatalf("failed source destination was not detached: %#v", fresh) + } +} + +func facadeDestinationMap(t *testing.T, value map[string]any, keys ...string) map[string]any { + t.Helper() + for _, key := range keys { + child, ok := value[key].(map[string]any) + if !ok { + t.Fatalf("%q = %#v, want mapping", key, value[key]) + } + value = child + } + return value +} + +func facadeDestinationValue(t *testing.T, value map[string]any, keys ...string) any { + t.Helper() + return facadeDestinationMap(t, value, keys[:len(keys)-1]...)[keys[len(keys)-1]] +} diff --git a/layered.go b/layered.go index adebc82..f63758b 100644 --- a/layered.go +++ b/layered.go @@ -2,13 +2,17 @@ package configkit import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" -// Input selects one source, whether its imports are enabled, and any explicit -// filesystem context. A zero Source is invalid; use NoSource() for an omitted -// main input. Sources keep their documented reuse and reader ownership rules. +// Input selects one source, its application destination, whether its imports are +// enabled, and any explicit filesystem context. A zero Source is invalid; use +// NoSource() for an omitted main input. Sources keep their documented reuse and +// reader ownership rules. type Input struct { - Source Source - Imports bool - Filesystem *FilesystemContext + Source Source + // Destination selects the mapping path where Source and its imported + // application contributions are placed. Its zero value keeps root placement. + Destination Path + Imports bool + Filesystem *FilesystemContext } // Underlay contains inputs that must compose strictly within one tier. Tiers @@ -46,7 +50,7 @@ func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T], func toEngineInput(input Input) engine.Input { return engine.Input{ Source: input.Source.toEngine(), Imports: input.Imports, - Filesystem: toEngineFilesystemContext(input.Filesystem), + Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination), } } diff --git a/records.go b/records.go index 5c77f07..92a2688 100644 --- a/records.go +++ b/records.go @@ -182,12 +182,16 @@ type Location struct { // SourceInfo describes a source discovered during a load. Kind identifies its // acquisition, independent of Layer. Path is canonical for a host file and // namespace-relative for a filesystem file; synthetic sources have no Path. +// Destination is the effective mapping path where this source contributes. // UnderlayIndex is the one-based original underlay position, or zero for other // sources. ImportedFrom identifies the declaration that included this source. type SourceInfo struct { - ID string - Label string - Path string + ID string + Label string + Path string + // Destination is the effective mapping path. Imported sources retain their + // input's destination; overlay and CLI sources use the root path. + Destination Path Layer Layer Kind SourceKind UnderlayIndex int @@ -260,6 +264,7 @@ func cloneLocations(locations []Location) []Location { } func cloneSourceInfo(source SourceInfo) SourceInfo { + source.Destination = source.Destination.clone() source.ImportedFrom = cloneLocation(source.ImportedFrom) return source } diff --git a/records_test.go b/records_test.go index 7755e3b..3f4c7f8 100644 --- a/records_test.go +++ b/records_test.go @@ -20,3 +20,21 @@ func TestOverlayPublicValues(t *testing.T) { } } } + +func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) { + destination := Keys("collections", "a/b", "0") + public := SourceInfo{ID: "source-1", Destination: destination} + internal := toEngineSourceInfo(public) + if internal.Destination.String() != destination.String() { + t.Fatalf("engine destination = %q, want %q", internal.Destination.String(), destination.String()) + } + converted := fromEngineSourceInfo(internal) + if converted.Destination.String() != destination.String() { + t.Fatalf("public destination = %q, want %q", converted.Destination.String(), destination.String()) + } + copy := cloneSourceInfo(converted) + copy.Destination = Path{} + if converted.Destination.String() != destination.String() { + t.Fatalf("source record destination changed through clone: %q", converted.Destination.String()) + } +}