diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6cb6829..384d3e2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Pre-v1 Public API Cleanup Implementation Plan -Status: in progress; Stages 1–5 are complete and Stages 6–11 are pending. +Status: in progress; Stages 1–6 are complete and Stages 7–11 are pending. Baseline: `07e4ae5d9236935389e81b207d4a8f00543fafce`. Audience: a Luna coding agent executing the stages sequentially. @@ -254,7 +254,7 @@ bootstrap behavior remain unchanged. ## Stage 6 — Replace Kind-Selecting Recording With Intent Methods -Status: pending. Implements D6 and N4. +Status: complete. Implements D6 and N4. 1. Replace root and engine `Resolution.Record` with: diff --git a/examples/consumers/destinations.go b/examples/consumers/destinations.go index 27268dc..f84aa56 100644 --- a/examples/consumers/destinations.go +++ b/examples/consumers/destinations.go @@ -104,9 +104,9 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio } prompt.ResourceText = string(data) collection.Prompts[promptID] = prompt - resolution.Record( + resolution.RecordNormalization( configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"), - configkit.ChangeNormalization, "read source-relative prompt content", contentPath, + "read source-relative prompt content", contentPath, ) } configuration.Collections[collectionID] = collection diff --git a/examples/consumers/diff.go b/examples/consumers/diff.go index 84acd0a..61bc59f 100644 --- a/examples/consumers/diff.go +++ b/examples/consumers/diff.go @@ -101,8 +101,8 @@ func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resoluti url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port) if endpoint.URL != url { endpoint.URL = url - resolution.Record(configkit.Keys("service", "endpoint", "url"), - configkit.ChangeNormalization, "derived endpoint URL", + resolution.RecordNormalization(configkit.Keys("service", "endpoint", "url"), + "derived endpoint URL", configkit.Keys("service", "endpoint", "host"), configkit.Keys("service", "endpoint", "port")) } diff --git a/examples/consumers/sequence_catalog.go b/examples/consumers/sequence_catalog.go index 0174a2b..d330c59 100644 --- a/examples/consumers/sequence_catalog.go +++ b/examples/consumers/sequence_catalog.go @@ -54,7 +54,7 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res }, Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error { configuration.ResolvedLabel = "for:" + configuration.RunLabel - resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization, + resolution.RecordNormalization(configkit.Keys("resolved_label"), "derive the resolved label from the selected run label", configkit.Keys("run_label")) return nil }, diff --git a/facade_candidate_test.go b/facade_candidate_test.go index a992e79..b774b6f 100644 --- a/facade_candidate_test.go +++ b/facade_candidate_test.go @@ -245,6 +245,8 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) { } for _, context := range []func(){ func() { retainedResolution.IsExplicit(configkit.Keys("input")) }, + func() { retainedResolution.RecordDerivedDefault(configkit.Keys("input"), "too late") }, + func() { retainedResolution.RecordNormalization(configkit.Keys("input"), "too late") }, func() { retainedValidation.Warning(configkit.Keys("input"), "too late") }, } { func() { diff --git a/facade_context_test.go b/facade_context_test.go index a8cf968..580ff83 100644 --- a/facade_context_test.go +++ b/facade_context_test.go @@ -27,7 +27,8 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) { {"EntryInfo", func() { state.resolution.EntryInfo(path) }}, {"Source", func() { state.resolution.Source("source") }}, {"SourceContext", func() { state.resolution.SourceContext("source") }}, - {"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }}, + {"RecordDerivedDefault", func() { state.resolution.RecordDerivedDefault(path, "reason") }}, + {"RecordNormalization", func() { state.resolution.RecordNormalization(path, "reason") }}, {"Resolution.Error", func() { state.resolution.Error(path, "message") }}, {"Resolution.Warning", func() { state.resolution.Warning(path, "message") }}, {"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }}, diff --git a/facade_destination_test.go b/facade_destination_test.go index e16cbee..dcfe9b5 100644 --- a/facade_destination_test.go +++ b/facade_destination_test.go @@ -370,7 +370,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) { } 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) + resolution.RecordNormalization(configkit.Keys("catalog", "entries", "item", "resource_text"), "read source-relative summary", resourcePath) return nil }, Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ }, diff --git a/facade_diff_test.go b/facade_diff_test.go index 60d2c24..b7052da 100644 --- a/facade_diff_test.go +++ b/facade_diff_test.go @@ -32,7 +32,7 @@ func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testin Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/secret" }, Resolve: func(current *configuration, resolution *configkit.Resolution) error { current.Items[0] = "public scalar" - resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "normalize first item") + resolution.RecordNormalization(configkit.Keys("items"), "normalize first item") return nil }, }}) @@ -74,7 +74,7 @@ func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) { Sensitive: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" }, Resolve: func(current *configuration, resolution *configkit.Resolution) error { delete(current.Dynamic, "entry") - resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry") + resolution.RecordNormalization(configkit.Keys("dynamic", "entry"), "remove entry") return nil }, }} @@ -221,7 +221,7 @@ func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) { resolve := func(value string) configkit.Options[config] { return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error { current.Count = len(value) + 8 - resolution.Record(configkit.Keys("count"), configkit.ChangeNormalization, "set effective count") + resolution.RecordNormalization(configkit.Keys("count"), "set effective count") return nil }}} } diff --git a/facade_discovery_test.go b/facade_discovery_test.go index 8201a58..dd1f81c 100644 --- a/facade_discovery_test.go +++ b/facade_discovery_test.go @@ -237,7 +237,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error { resolveCalls++ configuration.Resolved = "from:" + configuration.Value - resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value")) + resolution.RecordNormalization(Keys("resolved"), "derive from selected value", Keys("value")) return nil }, Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ }, diff --git a/facade_final_sequence_identity_test.go b/facade_final_sequence_identity_test.go index 997036d..d44d6d1 100644 --- a/facade_final_sequence_identity_test.go +++ b/facade_final_sequence_identity_test.go @@ -29,7 +29,7 @@ func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) { if phase == "resolution" { definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { (*value)["items"] = items - resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload") + resolution.RecordNormalization(configkit.Keys("items"), "supply application payload") return nil } } @@ -77,7 +77,7 @@ func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) { case "resolution": options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { (*value)["items"] = items - resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records") + resolution.RecordNormalization(configkit.Keys("items"), "create effective records") return nil } } @@ -134,7 +134,7 @@ func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) { case "resolution": options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { (*value)["collections"] = map[string]any{name: map[string]any{"items": items}} - resolution.Record(configkit.Keys("collections"), configkit.ChangeNormalization, "create namespaced records") + resolution.RecordNormalization(configkit.Keys("collections"), "create namespaced records") return nil } } diff --git a/facade_historical_paths_test.go b/facade_historical_paths_test.go index e1aacc1..348f31b 100644 --- a/facade_historical_paths_test.go +++ b/facade_historical_paths_test.go @@ -62,7 +62,7 @@ func TestPublicHistoryUsesResolutionShapeAttribution(t *testing.T) { Definition: configkit.Definition[map[string]any]{ Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error { (*configuration)["shape"] = []any{"replacement"} - resolution.Record(configkit.Keys("shape"), configkit.ChangeNormalization, "replace numeric mapping key") + resolution.RecordNormalization(configkit.Keys("shape"), "replace numeric mapping key") return nil }, }, diff --git a/facade_resolution_recording_test.go b/facade_resolution_recording_test.go new file mode 100644 index 0000000..e7c4b27 --- /dev/null +++ b/facade_resolution_recording_test.go @@ -0,0 +1,43 @@ +package configkit + +import "testing" + +func TestFacadeResolutionRecordingIntents(t *testing.T) { + type configuration struct { + Derived string `yaml:"derived"` + Normalized string `yaml:"normalized"` + } + + result, err := Load(Bytes("recording.yml", []byte("derived: explicit\nnormalized: before\n")), Options[configuration]{ + Definition: Definition[configuration]{Resolve: func(value *configuration, resolution *Resolution) error { + value.Derived = "changed explicit input" + resolution.RecordDerivedDefault(Keys("derived"), "derive explicit input") + value.Normalized = "after" + resolution.RecordNormalization(Keys("normalized"), "normalize configured input") + return nil + }}, + }) + if err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + path Path + kind ChangeKind + }{ + {path: Keys("derived"), kind: ChangeDerivedDefault}, + {path: Keys("normalized"), kind: ChangeNormalization}, + } { + info, ok := result.Metadata().Lookup(test.path) + if !ok || info.Origin.Operation != test.kind { + t.Errorf("metadata %s = %#v, %v; want operation %q", test.path, info, ok, test.kind) + } + history := result.Metadata().History(test.path) + if len(history) == 0 || history[len(history)-1].Origin.Operation != test.kind { + t.Errorf("history %s = %#v; want final operation %q", test.path, history, test.kind) + } + } + if !result.Metadata().IsExplicit(Keys("derived")) { + t.Fatal("recording a derived default suppressed explicit-input provenance") + } +} diff --git a/facade_source_version_mode_test.go b/facade_source_version_mode_test.go index 3787ea9..c7b838f 100644 --- a/facade_source_version_mode_test.go +++ b/facade_source_version_mode_test.go @@ -807,7 +807,7 @@ func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t * }, Resolve: func(current *configuration, resolution *configkit.Resolution) error { current.Service.Resolved = "resolved once" - resolution.Record(configkit.Keys("service", "resolved"), configkit.ChangeNormalization, "derive resolved value") + resolution.RecordNormalization(configkit.Keys("service", "resolved"), "derive resolved value") return nil }, Validate: func(current *configuration, validation *configkit.Validation) { diff --git a/internal/engine/callback_ownership_test.go b/internal/engine/callback_ownership_test.go index 190b669..901d4b8 100644 --- a/internal/engine/callback_ownership_test.go +++ b/internal/engine/callback_ownership_test.go @@ -20,9 +20,9 @@ func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) { definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error { Normalize(r, Keys("map"), &c.Map, shared, "populate") c.Pointer = external - r.Record(Keys("pointer"), ChangeNormalization, "populate") + r.RecordNormalization(Keys("pointer"), "populate") c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET") - r.Record(Keys("secret"), ChangeNormalization, "reference") + r.RecordNormalization(Keys("secret"), "reference") return nil }} if validatorAssignment { diff --git a/internal/engine/destination_import_lifecycle_test.go b/internal/engine/destination_import_lifecycle_test.go index 4cd073b..ae528b0 100644 --- a/internal/engine/destination_import_lifecycle_test.go +++ b/internal/engine/destination_import_lifecycle_test.go @@ -70,7 +70,7 @@ func TestRelativeImportsFeedFinalInheritanceAndInspectionLifecycle(t *testing.T) child := config.Catalog.Entries["child"] child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height) config.Catalog.Entries["child"] = child - resolution.Record(Keys("catalog", "entries", "child", "derived"), ChangeNormalization, "derive final child summary", Keys("catalog", "entries", "child", "label"), Keys("catalog", "entries", "child", "dynamic"), Keys("catalog", "entries", "child", "height")) + resolution.RecordNormalization(Keys("catalog", "entries", "child", "derived"), "derive final child summary", Keys("catalog", "entries", "child", "label"), Keys("catalog", "entries", "child", "dynamic"), Keys("catalog", "entries", "child", "height")) return nil }, Validate: func(config *configuration, validation *Validation) { diff --git a/internal/engine/destination_integration_test.go b/internal/engine/destination_integration_test.go index 9f82781..4345dbd 100644 --- a/internal/engine/destination_integration_test.go +++ b/internal/engine/destination_integration_test.go @@ -287,7 +287,7 @@ func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testi Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error { observed.resolve++ configuration.Tenant.Derived = configuration.Tenant.Dynamic - resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from mounted input", Keys("tenant", "dynamic")) + resolution.RecordNormalization(Keys("tenant", "derived"), "derive from mounted input", Keys("tenant", "dynamic")) return nil }, Validate: func(configuration *destinationLifecycleConfig, _ *Validation) { @@ -425,7 +425,7 @@ func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) { definition: Definition[config]{ Resolve: func(configuration *config, resolution *Resolution) error { configuration.Tenant.Derived = configuration.Tenant.Dynamic - resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from dynamic input", Keys("tenant", "dynamic")) + resolution.RecordNormalization(Keys("tenant", "derived"), "derive from dynamic input", Keys("tenant", "dynamic")) return nil }, Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" }, diff --git a/internal/engine/diff_operation_test.go b/internal/engine/diff_operation_test.go index 4b7e0b1..9093792 100644 --- a/internal/engine/diff_operation_test.go +++ b/internal/engine/diff_operation_test.go @@ -29,7 +29,7 @@ func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) { Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error { resolveCalls++ configuration.Derived = configuration.Dynamic["visible"].(string) - resolution.Record(Keys("derived"), ChangeNormalization, "derive from effective values", Keys("credential")) + resolution.RecordNormalization(Keys("derived"), "derive from effective values", Keys("credential")) return nil }, }} @@ -203,7 +203,7 @@ func TestDiffProtectsHistoricalSensitiveRemovalAndReplacement(t *testing.T) { t.Run("removed historical value", func(t *testing.T) { left := load("dynamic: {token: removed-canary}\n", func(configuration *configuration, resolution *Resolution) error { delete(configuration.Dynamic, "token") - resolution.Record(secretPath, ChangeNormalization, "remove token") + resolution.RecordNormalization(secretPath, "remove token") return nil }) right := load("dynamic: {token: current-canary}\n", nil) @@ -228,7 +228,7 @@ func TestDiffProtectsHistoricalSensitiveRemovalAndReplacement(t *testing.T) { t.Run("replaced historical value", func(t *testing.T) { left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error { configuration.Dynamic["token"] = "replacement-canary" - resolution.Record(secretPath, ChangeNormalization, "replace token") + resolution.RecordNormalization(secretPath, "replace token") return nil }) right := load("dynamic: {token: other-canary}\n", nil) diff --git a/internal/engine/explain_test.go b/internal/engine/explain_test.go index e6ab902..b1c9288 100644 --- a/internal/engine/explain_test.go +++ b/internal/engine/explain_test.go @@ -525,10 +525,10 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) result, err := testLoad(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error { c.M["new"] = map[string]any{"child": int64(7)} delete(c.M, "old") - r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old")) - r.Record(Keys("m", "old"), ChangeNormalization, "remove old") + r.RecordDerivedDefault(Keys("m", "new"), "add nested", Keys("m", "old")) + r.RecordNormalization(Keys("m", "old"), "remove old") if overlap { - r.Record(Keys("m"), ChangeNormalization, "update container") + r.RecordNormalization(Keys("m"), "update container") } return nil }}}) @@ -587,7 +587,7 @@ func TestExplanationReplayDescriptorsRemainDetachedAcrossQueries(t *testing.T) { result, err := testLoad(Bytes("resolution-explanation.yml", []byte("count: 1\n")), Options[config]{ Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error { value.Count = 2 - resolution.Record(Keys("count"), ChangeNormalization, "normalized", Keys("count")) + resolution.RecordNormalization(Keys("count"), "normalized", Keys("count")) return nil }}, }) @@ -635,7 +635,7 @@ func TestConcurrentReadOnlyInspectionQueries(t *testing.T) { result, err := testLoad(Bytes("concurrent-inspection.yml", []byte("group: {value: input}\n")), Options[config]{ Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error { value.Group.Value = "resolved" - resolution.Record(Keys("group", "value"), ChangeNormalization, "normalized", Keys("group", "value")) + resolution.RecordNormalization(Keys("group", "value"), "normalized", Keys("group", "value")) return nil }}, }) diff --git a/internal/engine/history_budget_test.go b/internal/engine/history_budget_test.go index ea3c47c..ffb0ae4 100644 --- a/internal/engine/history_budget_test.go +++ b/internal/engine/history_budget_test.go @@ -149,8 +149,8 @@ func TestLayeredHistoryBudgetRejectsBeforeSecretsAndValidation(t *testing.T) { definition: Definition[historyBudgetLayeredConfig]{ Resolve: func(configuration *historyBudgetLayeredConfig, resolution *Resolution) error { configuration.Child.Value += "-resolved" - resolution.Record(Keys("child", "value"), ChangeNormalization, "normalize child") - resolution.Record(Keys("child"), ChangeDerivedDefault, "record enclosing child state") + resolution.RecordNormalization(Keys("child", "value"), "normalize child") + resolution.RecordDerivedDefault(Keys("child"), "record enclosing child state") return nil }, Validate: func(*historyBudgetLayeredConfig, *Validation) { counts.validations++ }, diff --git a/internal/engine/metadata_test.go b/internal/engine/metadata_test.go index 87361fc..c318dc5 100644 --- a/internal/engine/metadata_test.go +++ b/internal/engine/metadata_test.go @@ -290,7 +290,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) { configurationAtEntry := decoded.value.Interface().(configuration) resolved := runResolution(&configurationAtEntry, func(value *configuration, resolution *Resolution) error { value.Items[0] = "normalized" - resolution.Record(Keys("items"), ChangeNormalization, "normalized item") + resolution.RecordNormalization(Keys("items"), "normalized item") return nil }, schema, decoded, document, input) if len(resolved.diagnostics) != 0 { diff --git a/internal/engine/override_context_test.go b/internal/engine/override_context_test.go index ce54f40..e067439 100644 --- a/internal/engine/override_context_test.go +++ b/internal/engine/override_context_test.go @@ -85,7 +85,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin resolvedContext, resolveDiagnostics := resolveLoadInput(schema, Definition[config]{Resolve: func(value *config, resolution *Resolution) error { value.Count = 6 - resolution.Record(Keys("count"), ChangeNormalization, "normalize count") + resolution.RecordNormalization(Keys("count"), "normalize count") return nil }}, input, preparePostDecodeOverrideContext(input, decoded, assigned)) if len(resolveDiagnostics) != 0 { @@ -130,7 +130,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin Default(resolution, Keys("count"), &value.Count, 99, "fill missing count") Default(resolution, Keys("derived"), &value.Derived, 42, "derive default") value.APIKey = SecretEnv("RESOLVED_SECRET") - resolution.Record(Keys("api_key"), ChangeNormalization, "select effective secret reference") + resolution.RecordNormalization(Keys("api_key"), "select effective secret reference") return nil } resolved := runResolution(&configuration, resolver, schema, prepared.decoded, prepared.referenceInput, prepared.resolution) diff --git a/internal/engine/override_history_order_test.go b/internal/engine/override_history_order_test.go index 2d9cc60..5178793 100644 --- a/internal/engine/override_history_order_test.go +++ b/internal/engine/override_history_order_test.go @@ -39,7 +39,7 @@ func TestCLIHistoryFollowsInheritanceAndPrecedesResolution(t *testing.T) { } else { (*value)["child"].(map[string]any)["value"] = "resolved" } - resolution.Record(recorded, ChangeNormalization, "normalize after CLI", parent) + resolution.RecordNormalization(recorded, "normalize after CLI", parent) return nil } } diff --git a/internal/engine/provenance_test.go b/internal/engine/provenance_test.go index 2d55d3b..f013abc 100644 --- a/internal/engine/provenance_test.go +++ b/internal/engine/provenance_test.go @@ -62,7 +62,7 @@ empty: {} Normalize(resolution, Keys("values"), &configuration.Values, []int{5}, "bounded values", Keys("shared", "imported")) delete(configuration.Items, "remove") - resolution.Record(Keys("items"), ChangeNormalization, "removed disabled items") + resolution.RecordNormalization(Keys("items"), "removed disabled items") Default(resolution, Keys("derived"), &configuration.Derived, 7, "derived from equal", Keys("shared", "equal"), Keys("shared", "equal")) resolution.Warning(Keys("values"), "values were bounded", Keys("shared", "imported")) @@ -434,7 +434,7 @@ func TestResolutionInputRemainsFixedAcrossRecordedMutations(t *testing.T) { Normalize(resolution, Keys("existing"), &value.Existing, "normalized", "normalized existing value") value.Values["destination"] = value.Values["original"] delete(value.Values, "original") - resolution.Record(Keys("values"), ChangeNormalization, "moved configured value") + resolution.RecordNormalization(Keys("values"), "moved configured value") againOriginal, againOriginalFound := resolution.InputInfo(Keys("values", "original")) againDestination, againDestinationFound := resolution.InputInfo(Keys("values", "destination")) againExisting, againExistingFound := resolution.InputInfo(Keys("existing")) diff --git a/internal/engine/records.go b/internal/engine/records.go index 0807a7b..13ff9a9 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -129,7 +129,6 @@ const ( CodeResolveWarning Code = "resolve.warning" CodeResolveHookFailed Code = "resolve.hook_failed" CodeResolveInvalidPath Code = "resolve.invalid_path" - CodeResolveInvalidKind Code = "resolve.invalid_kind" CodeResolveInvalidRecord Code = "resolve.invalid_record" CodeResolveInvalidTarget Code = "resolve.invalid_target" CodeResolveInvalidValue Code = "resolve.invalid_value" diff --git a/internal/engine/resolution.go b/internal/engine/resolution.go index 0d3818b..e64330f 100644 --- a/internal/engine/resolution.go +++ b/internal/engine/resolution.go @@ -112,11 +112,21 @@ func (r *Resolution) SourceContext(id string) (ResourceContext, bool) { return r.activeState().input.sourceContext(id) } -// Record declares the intent for a resolver mutation. A container path covers -// mutations to its descendants. -func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) { +// RecordDerivedDefault declares a derived-default intent for a resolver +// mutation already performed by application code. A container path covers +// mutations to its descendants. This does not assign a value or suppress work +// for explicit input. +func (r *Resolution) RecordDerivedDefault(path Path, reason string, dependencies ...Path) { state := r.activeState() - state.addRecord(path, kind, reason, dependencies, nil) + state.addRecord(path, ChangeDerivedDefault, reason, dependencies, nil) +} + +// RecordNormalization declares a normalization intent for a resolver mutation +// already performed by application code. A container path covers mutations to +// its descendants. This does not assign a value. +func (r *Resolution) RecordNormalization(path Path, reason string, dependencies ...Path) { + state := r.activeState() + state.addRecord(path, ChangeNormalization, reason, dependencies, nil) } // Error reports a safe, value-free resolution error at path. @@ -219,10 +229,6 @@ func (state *resolutionState) addRecord(path Path, kind ChangeKind, reason strin if !state.validPath(path) { return } - if kind != ChangeDerivedDefault && kind != ChangeNormalization { - state.addDiagnostic(CodeResolveInvalidKind, path, "resolution record has an invalid change kind") - return - } if reason == "" { state.addDiagnostic(CodeResolveInvalidRecord, path, "resolution record requires a safe reason") return diff --git a/internal/engine/resolution_test.go b/internal/engine/resolution_test.go index 1b7eedc..6cd03cc 100644 --- a/internal/engine/resolution_test.go +++ b/internal/engine/resolution_test.go @@ -167,7 +167,7 @@ func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t result, err := testLoad(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{ Resolve: func(configuration *config, resolution *Resolution) error { delete(configuration.Items, "remove") - resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries") + resolution.RecordNormalization(Keys("items"), "removed disabled entries") return nil }, }}) @@ -259,12 +259,8 @@ func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) { Normalize(resolution, Keys("value"), &unrelated, 2, "changed value") return nil }, code: CodeResolveInvalidTarget}, - {name: "invalid record kind", resolve: func(_ *config, resolution *Resolution) error { - resolution.Record(Keys("value"), ChangeKind("other"), "invalid kind") - return nil - }, code: CodeResolveInvalidKind}, {name: "invalid path", resolve: func(_ *config, resolution *Resolution) error { - resolution.Record(Keys("unknown"), ChangeNormalization, "unknown field") + resolution.RecordNormalization(Keys("unknown"), "unknown field") return nil }, code: CodeResolveInvalidPath}, } @@ -361,7 +357,7 @@ func TestResolutionPresenceUsesEntryShapeAcrossDynamicChanges(t *testing.T) { } } - resolution.Record(Keys("dynamic"), ChangeNormalization, "change dynamic shape") + resolution.RecordNormalization(Keys("dynamic"), "change dynamic shape") child := "" if test.keyPresent { configuration.Dynamic = []any{} @@ -466,7 +462,7 @@ func TestResolutionMetadataExplicitnessUsesEntryShape(t *testing.T) { callbackErr = err return err } - resolution.Record(Keys("shape"), ChangeNormalization, "normalize shape") + resolution.RecordNormalization(Keys("shape"), "normalize shape") test.mutate(configuration) if err := checkEntry(); err != nil { callbackErr = err @@ -605,6 +601,8 @@ func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) { }() call() } + requireClosed(t, func() { (*Resolution)(nil).RecordDerivedDefault(Keys("value"), "late") }) + requireClosed(t, func() { (*Resolution)(nil).RecordNormalization(Keys("value"), "late") }) requireClosed(t, func() { (*Resolution)(nil).InputInfo(Keys("value")) }) requireClosed(t, func() { (&Resolution{}).Source("source-1") }) @@ -617,6 +615,8 @@ func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) { } requireClosed(t, func() { successful.InputInfo(Keys("value")) }) requireClosed(t, func() { successful.Source("source-1") }) + requireClosed(t, func() { successful.RecordDerivedDefault(Keys("value"), "late") }) + requireClosed(t, func() { successful.RecordNormalization(Keys("value"), "late") }) var failed *Resolution if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { @@ -627,6 +627,8 @@ func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) { } requireClosed(t, func() { failed.InputInfo(Keys("value")) }) requireClosed(t, func() { failed.Source("source-1") }) + requireClosed(t, func() { failed.RecordDerivedDefault(Keys("value"), "late") }) + requireClosed(t, func() { failed.RecordNormalization(Keys("value"), "late") }) } func TestValidationContextPanicsAfterVersionCallback(t *testing.T) { diff --git a/internal/engine/sequence_registration_test.go b/internal/engine/sequence_registration_test.go index 8daebbb..d18fe02 100644 --- a/internal/engine/sequence_registration_test.go +++ b/internal/engine/sequence_registration_test.go @@ -436,7 +436,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) { resolved := definition resolved.Resolve = func(value *config, resolution *Resolution) error { value.Items[1].ID = value.Items[0].ID - resolution.Record(Keys("items"), ChangeNormalization, "registered identities collide") + resolution.RecordNormalization(Keys("items"), "registered identities collide") return nil } result, err = testLoad[config](Bytes("main", []byte("items: [{id: first}, {id: second}]\n")), Options[config]{Definition: resolved}) diff --git a/internal/engine/source_version_test.go b/internal/engine/source_version_test.go index 7fd0493..30416ab 100644 --- a/internal/engine/source_version_test.go +++ b/internal/engine/source_version_test.go @@ -1252,7 +1252,7 @@ func TestSourceVersionEvidencePropagatesThroughRecordedResolutionDependency(t *t }, Resolve: func(value *configuration, resolution *Resolution) error { value.Derived = value.Credentials.Password - resolution.Record(Keys("derived"), ChangeNormalization, "derive from credentials", Keys("credentials")) + resolution.RecordNormalization(Keys("derived"), "derive from credentials", Keys("credentials")) return nil }, }, diff --git a/lifecycle.go b/lifecycle.go index f134fce..1ab5092 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -259,10 +259,19 @@ func (r *Resolution) Source(id string) (SourceInfo, bool) { return fromEngineSourceInfo(value), ok } -// Record declares the intent for a resolver mutation. A container path covers -// mutations to its descendants. -func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) { - r.engineContext().Record(toEnginePath(path), engine.ChangeKind(kind), reason, toEnginePaths(dependencies)...) +// RecordDerivedDefault records a derived-default intent for a resolver mutation +// already performed by application code. A container path covers mutations to +// its descendants. This does not assign a value or suppress work for explicit +// input. +func (r *Resolution) RecordDerivedDefault(path Path, reason string, dependencies ...Path) { + r.engineContext().RecordDerivedDefault(toEnginePath(path), reason, toEnginePaths(dependencies)...) +} + +// RecordNormalization records a normalization intent for a resolver mutation +// already performed by application code. A container path covers mutations to +// its descendants. This does not assign a value. +func (r *Resolution) RecordNormalization(path Path, reason string, dependencies ...Path) { + r.engineContext().RecordNormalization(toEnginePath(path), reason, toEnginePaths(dependencies)...) } // Error reports a safe, value-free resolution error at path. diff --git a/records.go b/records.go index 1473db1..b1045da 100644 --- a/records.go +++ b/records.go @@ -130,7 +130,6 @@ const ( CodeResolveWarning Code = "resolve.warning" CodeResolveHookFailed Code = "resolve.hook_failed" CodeResolveInvalidPath Code = "resolve.invalid_path" - CodeResolveInvalidKind Code = "resolve.invalid_kind" CodeResolveInvalidRecord Code = "resolve.invalid_record" CodeResolveInvalidTarget Code = "resolve.invalid_target" CodeResolveInvalidValue Code = "resolve.invalid_value"