Separate input acquisition from composition

This commit is contained in:
2026-10-23 18:15:01 +00:00
parent f6b02dec03
commit 5fec16a768
5 changed files with 182 additions and 71 deletions

View File

@@ -1,6 +1,6 @@
# Sequence Identity And Combination Implementation Plan # Sequence Identity And Combination Implementation Plan
Status: implementation in progress; Stages 1–8 complete. Status: implementation in progress; Stages 1–9 complete.
Audience: a Luna coding agent implementing one numbered stage per prompt, in Audience: a Luna coding agent implementing one numbered stage per prompt, in
order. This plan starts at commit order. This plan starts at commit
@@ -387,6 +387,10 @@ is the next stage.
Validation: the focused suites above and the everyday check. This refactor must Validation: the focused suites above and the everyday check. This refactor must
not change source IDs or turn acquisition order into precedence order. not change source IDs or turn acquisition order into precedence order.
**Completion:** Implemented and verified with focused acquisition, scheduling,
placement, source-version, layered-equivalence, resource-lifetime, and context
tests plus `./scripts/check.sh`.
## Stage 10 — Separate Underlay-Group Acquisition From Composition ## Stage 10 — Separate Underlay-Group Acquisition From Composition
Scope: behavior-preserving group coordination in `load_request.go`, using the Scope: behavior-preserving group coordination in `load_request.go`, using the

View File

@@ -235,9 +235,6 @@ func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystem
} }
if len(diagnostics) == 0 { if len(diagnostics) == 0 {
state.scheduleAcquisitionScopes(&state.scope) state.scheduleAcquisitionScopes(&state.scope)
if !deferAssembly {
diagnostics = state.composePendingItems(&state.scope)
}
} }
closeErr := access.close() closeErr := access.close()
@@ -250,19 +247,19 @@ func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystem
if closeErr != nil { if closeErr != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)} return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
} }
if deferAssembly { acquisition := state.scope
acquisition := state.scope if mainIsItem {
if mainIsItem { localScope := state.scope
localScope := state.scope acquisition = acquisitionScope{
acquisition = acquisitionScope{ owner: mainNodeKey, destination: destination.clone(),
owner: mainNodeKey, destination: destination.clone(), contributions: []acquiredContribution{{
contributions: []acquiredContribution{{ source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(),
role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(), processing: processing, pendingItem: &localScope,
processing: processing, pendingItem: &localScope, }},
}},
}
} }
}
if deferAssembly {
if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil { if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic} return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
} }
@@ -271,20 +268,62 @@ func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystem
importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes, importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes,
}, cloneSourceInfos(state.sources), nil }, cloneSourceInfos(state.sources), nil
} }
if mainIsItem { acquired := &composedDocument{
localScope := state.scope acquisition: []acquisitionScope{cloneAcquisitionScope(acquisition)},
importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes,
}
input := assembledInput{
parsed: main, composition: acquired, acquisition: cloneAcquisitionScopes(acquired.acquisition),
sources: state.sources, versionState: versionState, accounting: accounting,
}
composed, diagnostics := composeAcquiredInputWithPolicy(input, policy)
return composed, cloneSourceInfos(state.sources), diagnostics
}
// composeAcquiredInputWithPolicy is the per-input adapter from retained
// acquisition scopes to the established strict-composition behavior. It uses
// the already acquired documents and graph; source handles are closed before
// this handoff.
func composeAcquiredInputWithPolicy(input assembledInput, policy sequencePolicy) (*composedDocument, []Diagnostic) {
if input.composition == nil || input.composition.root != nil || len(input.composition.acquisition) == 0 {
return input.composition, nil
}
acquired := input.composition
if len(acquired.acquisition) != 1 {
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "acquired input has an invalid composition scope", nil)}
}
scope := cloneAcquisitionScope(acquired.acquisition[0])
var rule *VersionRule
if input.versionState != nil {
rule = input.versionState.effectiveRule
}
accounting := input.accounting
if accounting == nil {
accounting = &importAccounting{sources: len(input.sources), bytes: acquired.inputBytes, nodes: acquired.inputNodes}
}
traversal := importTraversal{
accounting: accounting,
limits: defaultImportLimits(), rule: rule, versionState: input.versionState,
importGraph: acquired.importGraph.clone(),
}
if len(scope.contributions) == 1 && scope.contributions[0].role == importRoleMain && scope.contributions[0].pendingItem != nil {
main := scope.contributions[0]
localScope := cloneAcquisitionScope(*main.pendingItem)
if diagnostics := traversal.composePendingItems(&localScope); len(diagnostics) != 0 {
return nil, prependCompositionDiagnosticPaths(diagnostics, main.destination.Index(0))
}
documents := make([]*document.Node, 0, len(localScope.contributions)) documents := make([]*document.Node, 0, len(localScope.contributions))
for _, contribution := range localScope.contributions { for _, contribution := range localScope.contributions {
documents = append(documents, contribution.document) documents = append(documents, contribution.document)
} }
completed, failures := composeDocuments(documents...) completed, diagnostics := composeDocuments(documents...)
if len(failures) != 0 { if len(diagnostics) != 0 {
return nil, cloneSourceInfos(state.sources), prependCompositionDiagnosticPaths(failures, destination.Index(0)) return nil, prependCompositionDiagnosticPaths(diagnostics, main.destination.Index(0))
} }
if versionState != nil { if input.versionState != nil {
protectionRule := versionState.localRule protectionRule := input.versionState.localRule
if versionState.mode == sourceVersionModeDefault && protectionRule != nil { if input.versionState.mode == sourceVersionModeDefault && protectionRule != nil {
prefix := destination.Index(0) prefix := main.destination.Index(0)
relative, inside := relativeHistoryPath(prefix, protectionRule.Path) relative, inside := relativeHistoryPath(prefix, protectionRule.Path)
if !inside || !matchesPathShape(Path{segments: protectionRule.Path.segments[:len(prefix.segments)]}, prefix) { if !inside || !matchesPathShape(Path{segments: protectionRule.Path.segments[:len(prefix.segments)]}, prefix) {
protectionRule = nil protectionRule = nil
@@ -298,56 +337,60 @@ func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystem
if rule != nil { if rule != nil {
diagnostic.Path = rule.Path.clone() diagnostic.Path = rule.Path.clone()
} }
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic} return nil, []Diagnostic{*diagnostic}
} }
} }
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(localScope)} completed.acquisition = []acquisitionScope{cloneAcquisitionScope(localScope)}
projectNestedEvidence(completed, localScope) projectNestedEvidence(completed, localScope)
_, placed, err := placeDocumentWithMode(completed.root, destination, placementModeSequenceItem, body.Location, maxDocumentDepth, maxDocumentNodes) _, placed, err := placeDocumentWithMode(completed.root, main.destination, placementModeSequenceItem, main.payload.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil { if err != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")} return nil, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
} }
placementNodes := destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) if !main.placementCharged {
if accounting.nodes+placementNodes > limits.nodes { placementNodes := destinationWrapperNodeCountForMode(main.destination, placementModeSequenceItem)
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)} if accounting.nodes+placementNodes > traversal.limits.nodes {
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", main.source.ImportedFrom)}
}
accounting.nodes += placementNodes
} }
accounting.nodes += placementNodes composition, diagnostics := composeDocumentsWithPolicy(policy, placed)
composed, failures := composeDocumentsWithPolicy(policy, placed) if len(diagnostics) != 0 {
if composed != nil { return nil, diagnostics
synthetic := acquisitionScope{contributions: []acquiredContribution{{
source: cloneSourceInfo(mainInfo), payload: completed.root, document: placed,
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
destination: destination.clone(), processing: processing, nested: completed,
}}}
synthetic.owner = mainNodeKey
projectNestedEvidence(composed, synthetic)
projectAcquiredDestinations(composed, synthetic, state.sources)
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)}
composed.inputBytes = accounting.bytes
composed.inputNodes = accounting.nodes
composed.importGraph = state.importGraph.clone()
} }
return composed, cloneSourceInfos(state.sources), failures synthetic := acquisitionScope{owner: main.scopeOwner, contributions: []acquiredContribution{{
source: cloneSourceInfo(main.source), payload: completed.root, document: placed,
identity: main.identity, role: importRoleMain, scopeOwner: main.scopeOwner,
destination: main.destination.clone(), processing: main.processing, nested: completed,
}}}
projectNestedEvidence(composition, synthetic)
projectAcquiredDestinations(composition, synthetic, input.sources)
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)}
composition.inputBytes = accounting.bytes
composition.inputNodes = accounting.nodes
composition.importGraph = acquired.importGraph.clone()
return composition, nil
} }
documents := make([]*document.Node, 0, len(state.scope.contributions)) if diagnostics := traversal.composePendingItems(&scope); len(diagnostics) != 0 {
for _, contribution := range state.scope.contributions { return nil, diagnostics
}
documents := make([]*document.Node, 0, len(scope.contributions))
for _, contribution := range scope.contributions {
documents = append(documents, contribution.document) documents = append(documents, contribution.document)
} }
composed, diagnostics := composeDocumentsWithPolicy(policy, documents...) composition, diagnostics := composeDocumentsWithPolicy(policy, documents...)
if composed != nil { if len(diagnostics) != 0 {
if diagnostic := importedVersionClaim(documents, state.scope, composed, rule); diagnostic != nil { return nil, diagnostics
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
}
} }
if composed != nil { if diagnostic := importedVersionClaim(documents, scope, composition, rule); diagnostic != nil {
projectNestedEvidence(composed, state.scope) return nil, []Diagnostic{*diagnostic}
projectAcquiredDestinations(composed, state.scope, state.sources)
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(state.scope)}
composed.inputBytes = accounting.bytes
composed.inputNodes = accounting.nodes
composed.importGraph = state.importGraph.clone()
} }
return composed, cloneSourceInfos(state.sources), diagnostics projectNestedEvidence(composition, scope)
projectAcquiredDestinations(composition, scope, input.sources)
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(scope)}
composition.inputBytes = accounting.bytes
composition.inputNodes = accounting.nodes
composition.importGraph = acquired.importGraph.clone()
return composition, nil
} }
func reservePendingItemPlacements(scope *acquisitionScope, limits importLimits, accounting *importAccounting) *Diagnostic { func reservePendingItemPlacements(scope *acquisitionScope, limits importLimits, accounting *importAccounting) *Diagnostic {

View File

@@ -601,6 +601,65 @@ func TestProjectNestedEvidenceUsesMappedSequenceItemIndex(t *testing.T) {
} }
} }
func TestDeferredInputCompositionUsesRetainedAcquisitionScopes(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: item.yml
destination: [records]
placement: sequence-item
name: main
`)
itemPath := writeImportFile(t, directory, "item.yml", `configkit:
imports:
- path: helper.yml
id: item
`)
helperPath := writeImportFile(t, directory, "helper.yml", "value: acquired\n")
accounting := &importAccounting{}
input, diagnostics := assembleBaseInputTracked(File(mainPath), Options[struct{}]{
Imports: true, Filesystem: &FilesystemContext{Root: directory}, deferAssembly: true,
}, Path{}, accounting, nil, nil)
if len(diagnostics) != 0 {
t.Fatalf("acquire input: %#v", diagnostics)
}
if input.composition == nil || input.composition.root != nil || len(input.composition.acquisition) != 1 {
t.Fatalf("acquisition handoff was composed early: %#v", input.composition)
}
if len(input.sources) != 3 || input.sources[0].ID != "source-1" || input.sources[1].ID != "source-2" || input.sources[2].ID != "source-3" {
t.Fatalf("acquired source order/identity = %#v", input.sources)
}
if _, err := os.Stat(itemPath); err != nil {
t.Fatalf("item source disappeared before composition: %v", err)
}
if _, err := os.Stat(helperPath); err != nil {
t.Fatalf("helper source disappeared before composition: %v", err)
}
composition, diagnostics := compositionForInputWithPolicy(input, sequencePolicy{})
if len(diagnostics) != 0 {
t.Fatalf("compose acquired input: %#v", diagnostics)
}
if value, present := documentAtPath(composition.root, Keys("name")); !present || value.Text != "main" {
t.Fatalf("main value = %#v, %v", value, present)
}
for path, want := range map[string]string{
"/records/0/id": "item",
"/records/0/value": "acquired",
} {
value, present := documentAtPath(composition.root, mustParsePath(t, path))
if !present || value.Text != want {
t.Errorf("composed %s = %#v, %v; want %q", path, value, present, want)
}
}
if got, want := input.sources[1].Destination.String(), "/records"; got != want {
t.Errorf("item destination = %q, want %q", got, want)
}
if got, want := input.sources[2].Destination.String(), "/records/0"; got != want {
t.Errorf("helper destination = %q, want %q", got, want)
}
}
func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T) { func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T) {
t.Parallel() t.Parallel()
type config struct { type config struct {

View File

@@ -168,6 +168,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), placementMode: request.main.placementMode} mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), placementMode: request.main.placementMode}
mainOptions.importDefaults = snapshotImportDefaults(request.importDefaults) mainOptions.importDefaults = snapshotImportDefaults(request.importDefaults)
mainOptions.sequencePolicy = request.sequencePolicy mainOptions.sequencePolicy = request.sequencePolicy
mainOptions.deferAssembly = request.ordinary
mainOptions.SourceVersionMode = request.sourceVersionMode mainOptions.SourceVersionMode = request.sourceVersionMode
mainOptions.Definition.Version = request.protectionVersion() mainOptions.Definition.Version = request.protectionVersion()
mainOptions.Overrides = request.overrides mainOptions.Overrides = request.overrides
@@ -205,14 +206,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
} }
if request.sourceVersionMode == sourceVersionModeDefault { if request.ordinary && (input.document != nil || input.composition != nil) {
versionDiagnostics := checkVersion(request.definition.Version, input.document)
accumulator.add(versionDiagnostics...)
if hasErrors(versionDiagnostics) {
return nil, accumulator.loadError()
}
}
if request.ordinary && input.document != nil {
input.composition, diagnostics = compositionForInputWithPolicy(input, request.sequencePolicy) input.composition, diagnostics = compositionForInputWithPolicy(input, request.sequencePolicy)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
@@ -220,6 +214,13 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
} }
input.document = input.composition.root input.document = input.composition.root
} }
if request.sourceVersionMode == sourceVersionModeDefault {
versionDiagnostics := checkVersion(request.definition.Version, input.document)
accumulator.add(versionDiagnostics...)
if hasErrors(versionDiagnostics) {
return nil, accumulator.loadError()
}
}
if request.ordinary { if request.ordinary {
input, diagnostics = applyInputLayers(request.main.source, mainOptions, input) input, diagnostics = applyInputLayers(request.main.source, mainOptions, input)
} else { } else {

View File

@@ -16,6 +16,7 @@ type assembledInput struct {
sources []SourceInfo sources []SourceInfo
contexts map[string]ResourceContext contexts map[string]ResourceContext
versionState *sourceVersionState versionState *sourceVersionState
accounting *importAccounting
} }
// acquiredContribution keeps a source-local payload beside its placed document. // acquiredContribution keeps a source-local payload beside its placed document.
@@ -106,7 +107,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
parsed.source.Destination = destination.clone() parsed.source.Destination = destination.clone()
parsed.source.placementMode = options.placementMode parsed.source.placementMode = options.placementMode
} }
input := assembledInput{parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState} input := assembledInput{parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting}
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
input.sources = sourceInfos(parsed.source) input.sources = sourceInfos(parsed.source)
// Mapping value issues already identify application content. Other early // Mapping value issues already identify application content. Other early
@@ -265,6 +266,9 @@ func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic)
func compositionForInputWithPolicy(input assembledInput, policy sequencePolicy) (*composedDocument, []Diagnostic) { func compositionForInputWithPolicy(input assembledInput, policy sequencePolicy) (*composedDocument, []Diagnostic) {
if input.composition != nil { if input.composition != nil {
if input.composition.root == nil && len(input.composition.acquisition) != 0 {
return composeAcquiredInputWithPolicy(input, policy)
}
return input.composition, nil return input.composition, nil
} }
if input.document == nil { if input.document == nil {