Summary
The Orchestrator framework does not consistently perform true merge semantics for nested attributes when state=merged. Direct nested NDBaseModel fields are partially supported, but lists, dictionaries, lists of NDNestedModel, and lists of dictionaries are treated as atomic values. When a user supplies only part of a nested collection, the framework can replace the entire existing nested collection instead of merging the requested subset.
This can be misleading and destructive for users who expect state=merged to preserve unspecified nested configuration.
Current Behavior
NDStateMachine uses exclude_unset=True for state=merged diffs, then calls NDConfigCollection.merge(), which delegates to NDBaseModel.merge().
NDBaseModel.merge():
- only applies fields explicitly set by the proposed model
- recursively merges only when both current and proposed values are
NDBaseModel
- otherwise assigns the proposed value directly
The diff helper issubset() recursively compares dictionaries, but lists require full-list equality with the same length. This means a proposed list containing one nested item is not treated as a subset of an existing list containing multiple items.
Affected examples include:
FabricUpdateGroupModel.install_image_data.install_package_names
FabricUpdateGroupModel.update_report_checks
LocalUserModel.security_domains
- interface trunk VLAN mapping lists
- fabric models with nested list/dict collections
Problem
For state=merged, users reasonably expect omitted nested properties to remain unchanged. Today that is only reliable for direct nested model fields. Collection-like nested fields are replaced as a single property.
Example problem shape:
state: merged
config:
- name: existing_object
nested_collection:
- key: one_item_to_update
value: new_value
If the controller already has other entries in nested_collection, the framework may send only the proposed list and drop the omitted entries.
Suggested Fix
Implement a policy-aware deep merge engine shared by model diff and merge logic.
Recommended design:
- Add merge policy metadata to
NDBaseModel, for example:
merge_policies: ClassVar[dict[str, MergePolicy]] = {
"security_domains": MergePolicy.list_keyed("name"),
"update_report_checks": MergePolicy.list_keyed("report_check_name"),
"install_package_names": MergePolicy.list_union(),
}
-
Supported policies should include:
replace: current default, safest fallback
model_deep: recursive model merge
dict_deep: recursive dictionary merge
list_replace: atomic list replacement
list_union: merge primitive list values without duplicates
list_keyed: merge list entries by identifier/key
- possibly
list_model_identifier: use nested model get_identifier_value() where available
-
Align diff behavior with merge behavior:
get_diff(..., exclude_unset=True) should use the same policy tree as merge()
- a proposed keyed-list item that already matches one existing item should be
no_diff
- a proposed keyed-list item that differs should be
changed
- omitted list entries should not force replacement under
state=merged
-
Cache merge plans per model class:
- inspect Pydantic fields once
- avoid repeated full serialization where possible
- validate the merged model once after applying changes
-
Make module generation require explicit merge policy decisions for nested collection fields.
Alternatives Considered
Option A: Generic recursive merge for all dicts/lists
This is easy to implement but risky. Lists do not have universal semantics: some are sets, some are ordered lists, some are keyed collections, and some must be replaced atomically. A generic list merge could silently produce invalid ND payloads.
Option B: JSON Merge Patch style semantics
This gives a known merge model for dictionaries, but JSON Merge Patch treats arrays as replacement values. That does not solve keyed nested collections without extra policy metadata.
Option C: Policy-aware merge engine
This is the recommended approach. It is more work, but it keeps default behavior safe and lets each model declare the intended semantics for nested collections.
Risks
- Changing merge behavior globally can alter idempotency and payloads across many modules.
- Keyed list merge requires reliable identifiers for nested entries.
- Some ND APIs treat list order as meaningful.
- Some APIs require full nested payloads even for partial updates.
- Deep merge can add runtime cost if implemented with repeated dumps/validation.
- Diff and merge must be updated together; changing only merge can still produce false changes.
Test Plan
- Add model-level tests for direct nested models, nested dicts, primitive lists, keyed lists of
NDNestedModel, and lists of dicts.
- Add state-machine tests showing
state=merged preserves unspecified nested list entries.
- Add negative tests for fields declared as
replace.
- Add module-specific regression tests for at least:
- local user security domains
- fabric update group install image/report checks
- trunk VLAN mapping entries
- Add performance benchmarks for large configs and nested lists before enabling broad defaults.
Acceptance Criteria
state=merged preserves unspecified nested values for fields declared mergeable.
- Existing replace semantics remain available and are the default for ambiguous fields.
- Diff and merge use the same policy rules.
- Runtime impact is measured and acceptable for large module configs.
- Future modules have a documented way to declare nested merge behavior.
Summary
The Orchestrator framework does not consistently perform true merge semantics for nested attributes when
state=merged. Direct nestedNDBaseModelfields are partially supported, but lists, dictionaries, lists ofNDNestedModel, and lists of dictionaries are treated as atomic values. When a user supplies only part of a nested collection, the framework can replace the entire existing nested collection instead of merging the requested subset.This can be misleading and destructive for users who expect
state=mergedto preserve unspecified nested configuration.Current Behavior
NDStateMachineusesexclude_unset=Trueforstate=mergeddiffs, then callsNDConfigCollection.merge(), which delegates toNDBaseModel.merge().NDBaseModel.merge():NDBaseModelThe diff helper
issubset()recursively compares dictionaries, but lists require full-list equality with the same length. This means a proposed list containing one nested item is not treated as a subset of an existing list containing multiple items.Affected examples include:
FabricUpdateGroupModel.install_image_data.install_package_namesFabricUpdateGroupModel.update_report_checksLocalUserModel.security_domainsProblem
For
state=merged, users reasonably expect omitted nested properties to remain unchanged. Today that is only reliable for direct nested model fields. Collection-like nested fields are replaced as a single property.Example problem shape:
If the controller already has other entries in
nested_collection, the framework may send only the proposed list and drop the omitted entries.Suggested Fix
Implement a policy-aware deep merge engine shared by model diff and merge logic.
Recommended design:
NDBaseModel, for example:Supported policies should include:
replace: current default, safest fallbackmodel_deep: recursive model mergedict_deep: recursive dictionary mergelist_replace: atomic list replacementlist_union: merge primitive list values without duplicateslist_keyed: merge list entries by identifier/keylist_model_identifier: use nested modelget_identifier_value()where availableAlign diff behavior with merge behavior:
get_diff(..., exclude_unset=True)should use the same policy tree asmerge()no_diffchangedstate=mergedCache merge plans per model class:
Make module generation require explicit merge policy decisions for nested collection fields.
Alternatives Considered
Option A: Generic recursive merge for all dicts/lists
This is easy to implement but risky. Lists do not have universal semantics: some are sets, some are ordered lists, some are keyed collections, and some must be replaced atomically. A generic list merge could silently produce invalid ND payloads.
Option B: JSON Merge Patch style semantics
This gives a known merge model for dictionaries, but JSON Merge Patch treats arrays as replacement values. That does not solve keyed nested collections without extra policy metadata.
Option C: Policy-aware merge engine
This is the recommended approach. It is more work, but it keeps default behavior safe and lets each model declare the intended semantics for nested collections.
Risks
Test Plan
NDNestedModel, and lists of dicts.state=mergedpreserves unspecified nested list entries.replace.Acceptance Criteria
state=mergedpreserves unspecified nested values for fields declared mergeable.