Replace generic resolution recording with intent methods

This commit is contained in:
2026-10-27 15:12:38 +00:00
parent eafbd32489
commit 9833ca3aa3
30 changed files with 124 additions and 63 deletions

View File

@@ -1,6 +1,6 @@
# Pre-v1 Public API Cleanup Implementation Plan # 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`. Baseline: `07e4ae5d9236935389e81b207d4a8f00543fafce`.
Audience: a Luna coding agent executing the stages sequentially. 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 ## 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: 1. Replace root and engine `Resolution.Record` with:

View File

@@ -104,9 +104,9 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio
} }
prompt.ResourceText = string(data) prompt.ResourceText = string(data)
collection.Prompts[promptID] = prompt collection.Prompts[promptID] = prompt
resolution.Record( resolution.RecordNormalization(
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"), 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 configuration.Collections[collectionID] = collection

View File

@@ -101,8 +101,8 @@ func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resoluti
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port) url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
if endpoint.URL != url { if endpoint.URL != url {
endpoint.URL = url endpoint.URL = url
resolution.Record(configkit.Keys("service", "endpoint", "url"), resolution.RecordNormalization(configkit.Keys("service", "endpoint", "url"),
configkit.ChangeNormalization, "derived endpoint URL", "derived endpoint URL",
configkit.Keys("service", "endpoint", "host"), configkit.Keys("service", "endpoint", "host"),
configkit.Keys("service", "endpoint", "port")) configkit.Keys("service", "endpoint", "port"))
} }

View File

@@ -54,7 +54,7 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
}, },
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error { Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
configuration.ResolvedLabel = "for:" + configuration.RunLabel 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")) "derive the resolved label from the selected run label", configkit.Keys("run_label"))
return nil return nil
}, },

View File

@@ -245,6 +245,8 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
} }
for _, context := range []func(){ for _, context := range []func(){
func() { retainedResolution.IsExplicit(configkit.Keys("input")) }, 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() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
} { } {
func() { func() {

View File

@@ -27,7 +27,8 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
{"EntryInfo", func() { state.resolution.EntryInfo(path) }}, {"EntryInfo", func() { state.resolution.EntryInfo(path) }},
{"Source", func() { state.resolution.Source("source") }}, {"Source", func() { state.resolution.Source("source") }},
{"SourceContext", func() { state.resolution.SourceContext("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.Error", func() { state.resolution.Error(path, "message") }},
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }}, {"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }}, {"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},

View File

@@ -370,7 +370,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
} }
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item") entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
entry["resource_text"] = string(data) 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 return nil
}, },
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ }, Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },

View File

@@ -32,7 +32,7 @@ func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testin
Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/secret" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
Resolve: func(current *configuration, resolution *configkit.Resolution) error { Resolve: func(current *configuration, resolution *configkit.Resolution) error {
current.Items[0] = "public scalar" 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 return nil
}, },
}}) }})
@@ -74,7 +74,7 @@ func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
Sensitive: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
Resolve: func(current *configuration, resolution *configkit.Resolution) error { Resolve: func(current *configuration, resolution *configkit.Resolution) error {
delete(current.Dynamic, "entry") delete(current.Dynamic, "entry")
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry") resolution.RecordNormalization(configkit.Keys("dynamic", "entry"), "remove entry")
return nil return nil
}, },
}} }}
@@ -221,7 +221,7 @@ func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
resolve := func(value string) configkit.Options[config] { resolve := func(value string) configkit.Options[config] {
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error { return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
current.Count = len(value) + 8 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 return nil
}}} }}}
} }

View File

@@ -237,7 +237,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error { Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
resolveCalls++ resolveCalls++
configuration.Resolved = "from:" + configuration.Value 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 return nil
}, },
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ }, Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },

View File

@@ -29,7 +29,7 @@ func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
if phase == "resolution" { if phase == "resolution" {
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["items"] = items (*value)["items"] = items
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload") resolution.RecordNormalization(configkit.Keys("items"), "supply application payload")
return nil return nil
} }
} }
@@ -77,7 +77,7 @@ func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
case "resolution": case "resolution":
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["items"] = items (*value)["items"] = items
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records") resolution.RecordNormalization(configkit.Keys("items"), "create effective records")
return nil return nil
} }
} }
@@ -134,7 +134,7 @@ func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
case "resolution": case "resolution":
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}} (*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 return nil
} }
} }

View File

@@ -62,7 +62,7 @@ func TestPublicHistoryUsesResolutionShapeAttribution(t *testing.T) {
Definition: configkit.Definition[map[string]any]{ Definition: configkit.Definition[map[string]any]{
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error { Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
(*configuration)["shape"] = []any{"replacement"} (*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 return nil
}, },
}, },

View File

@@ -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")
}
}

View File

@@ -807,7 +807,7 @@ func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *
}, },
Resolve: func(current *configuration, resolution *configkit.Resolution) error { Resolve: func(current *configuration, resolution *configkit.Resolution) error {
current.Service.Resolved = "resolved once" 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 return nil
}, },
Validate: func(current *configuration, validation *configkit.Validation) { Validate: func(current *configuration, validation *configkit.Validation) {

View File

@@ -20,9 +20,9 @@ func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) {
definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error { definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
Normalize(r, Keys("map"), &c.Map, shared, "populate") Normalize(r, Keys("map"), &c.Map, shared, "populate")
c.Pointer = external c.Pointer = external
r.Record(Keys("pointer"), ChangeNormalization, "populate") r.RecordNormalization(Keys("pointer"), "populate")
c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET") c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET")
r.Record(Keys("secret"), ChangeNormalization, "reference") r.RecordNormalization(Keys("secret"), "reference")
return nil return nil
}} }}
if validatorAssignment { if validatorAssignment {

View File

@@ -70,7 +70,7 @@ func TestRelativeImportsFeedFinalInheritanceAndInspectionLifecycle(t *testing.T)
child := config.Catalog.Entries["child"] child := config.Catalog.Entries["child"]
child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height) child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height)
config.Catalog.Entries["child"] = child 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 return nil
}, },
Validate: func(config *configuration, validation *Validation) { Validate: func(config *configuration, validation *Validation) {

View File

@@ -287,7 +287,7 @@ func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testi
Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error { Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error {
observed.resolve++ observed.resolve++
configuration.Tenant.Derived = configuration.Tenant.Dynamic 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 return nil
}, },
Validate: func(configuration *destinationLifecycleConfig, _ *Validation) { Validate: func(configuration *destinationLifecycleConfig, _ *Validation) {
@@ -425,7 +425,7 @@ func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) {
definition: Definition[config]{ definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
configuration.Tenant.Derived = configuration.Tenant.Dynamic 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 return nil
}, },
Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" }, Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" },

View File

@@ -29,7 +29,7 @@ func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) {
Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error { Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error {
resolveCalls++ resolveCalls++
configuration.Derived = configuration.Dynamic["visible"].(string) 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 return nil
}, },
}} }}
@@ -203,7 +203,7 @@ func TestDiffProtectsHistoricalSensitiveRemovalAndReplacement(t *testing.T) {
t.Run("removed historical value", func(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 { left := load("dynamic: {token: removed-canary}\n", func(configuration *configuration, resolution *Resolution) error {
delete(configuration.Dynamic, "token") delete(configuration.Dynamic, "token")
resolution.Record(secretPath, ChangeNormalization, "remove token") resolution.RecordNormalization(secretPath, "remove token")
return nil return nil
}) })
right := load("dynamic: {token: current-canary}\n", 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) { t.Run("replaced historical value", func(t *testing.T) {
left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error { left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error {
configuration.Dynamic["token"] = "replacement-canary" configuration.Dynamic["token"] = "replacement-canary"
resolution.Record(secretPath, ChangeNormalization, "replace token") resolution.RecordNormalization(secretPath, "replace token")
return nil return nil
}) })
right := load("dynamic: {token: other-canary}\n", nil) right := load("dynamic: {token: other-canary}\n", nil)

View File

@@ -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 { 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)} c.M["new"] = map[string]any{"child": int64(7)}
delete(c.M, "old") delete(c.M, "old")
r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old")) r.RecordDerivedDefault(Keys("m", "new"), "add nested", Keys("m", "old"))
r.Record(Keys("m", "old"), ChangeNormalization, "remove old") r.RecordNormalization(Keys("m", "old"), "remove old")
if overlap { if overlap {
r.Record(Keys("m"), ChangeNormalization, "update container") r.RecordNormalization(Keys("m"), "update container")
} }
return nil return nil
}}}) }}})
@@ -587,7 +587,7 @@ func TestExplanationReplayDescriptorsRemainDetachedAcrossQueries(t *testing.T) {
result, err := testLoad(Bytes("resolution-explanation.yml", []byte("count: 1\n")), Options[config]{ result, err := testLoad(Bytes("resolution-explanation.yml", []byte("count: 1\n")), Options[config]{
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error { Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
value.Count = 2 value.Count = 2
resolution.Record(Keys("count"), ChangeNormalization, "normalized", Keys("count")) resolution.RecordNormalization(Keys("count"), "normalized", Keys("count"))
return nil 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]{ result, err := testLoad(Bytes("concurrent-inspection.yml", []byte("group: {value: input}\n")), Options[config]{
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error { Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
value.Group.Value = "resolved" 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 return nil
}}, }},
}) })

View File

@@ -149,8 +149,8 @@ func TestLayeredHistoryBudgetRejectsBeforeSecretsAndValidation(t *testing.T) {
definition: Definition[historyBudgetLayeredConfig]{ definition: Definition[historyBudgetLayeredConfig]{
Resolve: func(configuration *historyBudgetLayeredConfig, resolution *Resolution) error { Resolve: func(configuration *historyBudgetLayeredConfig, resolution *Resolution) error {
configuration.Child.Value += "-resolved" configuration.Child.Value += "-resolved"
resolution.Record(Keys("child", "value"), ChangeNormalization, "normalize child") resolution.RecordNormalization(Keys("child", "value"), "normalize child")
resolution.Record(Keys("child"), ChangeDerivedDefault, "record enclosing child state") resolution.RecordDerivedDefault(Keys("child"), "record enclosing child state")
return nil return nil
}, },
Validate: func(*historyBudgetLayeredConfig, *Validation) { counts.validations++ }, Validate: func(*historyBudgetLayeredConfig, *Validation) { counts.validations++ },

View File

@@ -290,7 +290,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
configurationAtEntry := decoded.value.Interface().(configuration) configurationAtEntry := decoded.value.Interface().(configuration)
resolved := runResolution(&configurationAtEntry, func(value *configuration, resolution *Resolution) error { resolved := runResolution(&configurationAtEntry, func(value *configuration, resolution *Resolution) error {
value.Items[0] = "normalized" value.Items[0] = "normalized"
resolution.Record(Keys("items"), ChangeNormalization, "normalized item") resolution.RecordNormalization(Keys("items"), "normalized item")
return nil return nil
}, schema, decoded, document, input) }, schema, decoded, document, input)
if len(resolved.diagnostics) != 0 { if len(resolved.diagnostics) != 0 {

View File

@@ -85,7 +85,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
resolvedContext, resolveDiagnostics := resolveLoadInput(schema, Definition[config]{Resolve: func(value *config, resolution *Resolution) error { resolvedContext, resolveDiagnostics := resolveLoadInput(schema, Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
value.Count = 6 value.Count = 6
resolution.Record(Keys("count"), ChangeNormalization, "normalize count") resolution.RecordNormalization(Keys("count"), "normalize count")
return nil return nil
}}, input, preparePostDecodeOverrideContext(input, decoded, assigned)) }}, input, preparePostDecodeOverrideContext(input, decoded, assigned))
if len(resolveDiagnostics) != 0 { 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("count"), &value.Count, 99, "fill missing count")
Default(resolution, Keys("derived"), &value.Derived, 42, "derive default") Default(resolution, Keys("derived"), &value.Derived, 42, "derive default")
value.APIKey = SecretEnv("RESOLVED_SECRET") 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 return nil
} }
resolved := runResolution(&configuration, resolver, schema, prepared.decoded, prepared.referenceInput, prepared.resolution) resolved := runResolution(&configuration, resolver, schema, prepared.decoded, prepared.referenceInput, prepared.resolution)

View File

@@ -39,7 +39,7 @@ func TestCLIHistoryFollowsInheritanceAndPrecedesResolution(t *testing.T) {
} else { } else {
(*value)["child"].(map[string]any)["value"] = "resolved" (*value)["child"].(map[string]any)["value"] = "resolved"
} }
resolution.Record(recorded, ChangeNormalization, "normalize after CLI", parent) resolution.RecordNormalization(recorded, "normalize after CLI", parent)
return nil return nil
} }
} }

View File

@@ -62,7 +62,7 @@ empty: {}
Normalize(resolution, Keys("values"), &configuration.Values, []int{5}, Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
"bounded values", Keys("shared", "imported")) "bounded values", Keys("shared", "imported"))
delete(configuration.Items, "remove") 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, Default(resolution, Keys("derived"), &configuration.Derived, 7,
"derived from equal", Keys("shared", "equal"), Keys("shared", "equal")) "derived from equal", Keys("shared", "equal"), Keys("shared", "equal"))
resolution.Warning(Keys("values"), "values were bounded", Keys("shared", "imported")) 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") Normalize(resolution, Keys("existing"), &value.Existing, "normalized", "normalized existing value")
value.Values["destination"] = value.Values["original"] value.Values["destination"] = value.Values["original"]
delete(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")) againOriginal, againOriginalFound := resolution.InputInfo(Keys("values", "original"))
againDestination, againDestinationFound := resolution.InputInfo(Keys("values", "destination")) againDestination, againDestinationFound := resolution.InputInfo(Keys("values", "destination"))
againExisting, againExistingFound := resolution.InputInfo(Keys("existing")) againExisting, againExistingFound := resolution.InputInfo(Keys("existing"))

View File

@@ -129,7 +129,6 @@ const (
CodeResolveWarning Code = "resolve.warning" CodeResolveWarning Code = "resolve.warning"
CodeResolveHookFailed Code = "resolve.hook_failed" CodeResolveHookFailed Code = "resolve.hook_failed"
CodeResolveInvalidPath Code = "resolve.invalid_path" CodeResolveInvalidPath Code = "resolve.invalid_path"
CodeResolveInvalidKind Code = "resolve.invalid_kind"
CodeResolveInvalidRecord Code = "resolve.invalid_record" CodeResolveInvalidRecord Code = "resolve.invalid_record"
CodeResolveInvalidTarget Code = "resolve.invalid_target" CodeResolveInvalidTarget Code = "resolve.invalid_target"
CodeResolveInvalidValue Code = "resolve.invalid_value" CodeResolveInvalidValue Code = "resolve.invalid_value"

View File

@@ -112,11 +112,21 @@ func (r *Resolution) SourceContext(id string) (ResourceContext, bool) {
return r.activeState().input.sourceContext(id) return r.activeState().input.sourceContext(id)
} }
// Record declares the intent for a resolver mutation. A container path covers // RecordDerivedDefault declares a derived-default intent for a resolver
// mutations to its descendants. // mutation already performed by application code. A container path covers
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) { // 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 := 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. // 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) { if !state.validPath(path) {
return return
} }
if kind != ChangeDerivedDefault && kind != ChangeNormalization {
state.addDiagnostic(CodeResolveInvalidKind, path, "resolution record has an invalid change kind")
return
}
if reason == "" { if reason == "" {
state.addDiagnostic(CodeResolveInvalidRecord, path, "resolution record requires a safe reason") state.addDiagnostic(CodeResolveInvalidRecord, path, "resolution record requires a safe reason")
return return

View File

@@ -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]{ 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 { Resolve: func(configuration *config, resolution *Resolution) error {
delete(configuration.Items, "remove") delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries") resolution.RecordNormalization(Keys("items"), "removed disabled entries")
return nil return nil
}, },
}}) }})
@@ -259,12 +259,8 @@ func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
Normalize(resolution, Keys("value"), &unrelated, 2, "changed value") Normalize(resolution, Keys("value"), &unrelated, 2, "changed value")
return nil return nil
}, code: CodeResolveInvalidTarget}, }, 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 { {name: "invalid path", resolve: func(_ *config, resolution *Resolution) error {
resolution.Record(Keys("unknown"), ChangeNormalization, "unknown field") resolution.RecordNormalization(Keys("unknown"), "unknown field")
return nil return nil
}, code: CodeResolveInvalidPath}, }, 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 := "" child := ""
if test.keyPresent { if test.keyPresent {
configuration.Dynamic = []any{} configuration.Dynamic = []any{}
@@ -466,7 +462,7 @@ func TestResolutionMetadataExplicitnessUsesEntryShape(t *testing.T) {
callbackErr = err callbackErr = err
return err return err
} }
resolution.Record(Keys("shape"), ChangeNormalization, "normalize shape") resolution.RecordNormalization(Keys("shape"), "normalize shape")
test.mutate(configuration) test.mutate(configuration)
if err := checkEntry(); err != nil { if err := checkEntry(); err != nil {
callbackErr = err callbackErr = err
@@ -605,6 +601,8 @@ func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) {
}() }()
call() 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)(nil).InputInfo(Keys("value")) })
requireClosed(t, func() { (&Resolution{}).Source("source-1") }) 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.InputInfo(Keys("value")) })
requireClosed(t, func() { successful.Source("source-1") }) 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 var failed *Resolution
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { 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.InputInfo(Keys("value")) })
requireClosed(t, func() { failed.Source("source-1") }) 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) { func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {

View File

@@ -436,7 +436,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
resolved := definition resolved := definition
resolved.Resolve = func(value *config, resolution *Resolution) error { resolved.Resolve = func(value *config, resolution *Resolution) error {
value.Items[1].ID = value.Items[0].ID 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 return nil
} }
result, err = testLoad[config](Bytes("main", []byte("items: [{id: first}, {id: second}]\n")), Options[config]{Definition: resolved}) result, err = testLoad[config](Bytes("main", []byte("items: [{id: first}, {id: second}]\n")), Options[config]{Definition: resolved})

View File

@@ -1252,7 +1252,7 @@ func TestSourceVersionEvidencePropagatesThroughRecordedResolutionDependency(t *t
}, },
Resolve: func(value *configuration, resolution *Resolution) error { Resolve: func(value *configuration, resolution *Resolution) error {
value.Derived = value.Credentials.Password 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 return nil
}, },
}, },

View File

@@ -259,10 +259,19 @@ func (r *Resolution) Source(id string) (SourceInfo, bool) {
return fromEngineSourceInfo(value), ok return fromEngineSourceInfo(value), ok
} }
// Record declares the intent for a resolver mutation. A container path covers // RecordDerivedDefault records a derived-default intent for a resolver mutation
// mutations to its descendants. // already performed by application code. A container path covers mutations to
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) { // its descendants. This does not assign a value or suppress work for explicit
r.engineContext().Record(toEnginePath(path), engine.ChangeKind(kind), reason, toEnginePaths(dependencies)...) // 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. // Error reports a safe, value-free resolution error at path.

View File

@@ -130,7 +130,6 @@ const (
CodeResolveWarning Code = "resolve.warning" CodeResolveWarning Code = "resolve.warning"
CodeResolveHookFailed Code = "resolve.hook_failed" CodeResolveHookFailed Code = "resolve.hook_failed"
CodeResolveInvalidPath Code = "resolve.invalid_path" CodeResolveInvalidPath Code = "resolve.invalid_path"
CodeResolveInvalidKind Code = "resolve.invalid_kind"
CodeResolveInvalidRecord Code = "resolve.invalid_record" CodeResolveInvalidRecord Code = "resolve.invalid_record"
CodeResolveInvalidTarget Code = "resolve.invalid_target" CodeResolveInvalidTarget Code = "resolve.invalid_target"
CodeResolveInvalidValue Code = "resolve.invalid_value" CodeResolveInvalidValue Code = "resolve.invalid_value"