Detect field removals in replaced/overridden states (one-way subset diff) - #422
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes diff classification for replaced/overridden Gen-3 state-machine runs by detecting “removal-only” changes (fields present in existing device config but omitted from proposed config), while preserving merged semantics and idempotency in the presence of ND default/empty echoes.
Changes:
- Add a reverse-pass removal detector (
utils.has_removals) and integrate it intoNDBaseModel.get_diffforexclude_unset=Falseto correctly trigger updates on field removals. - Normalize ND “empty marker” echoes (
"",[],{}, nested-empty dicts) and strip schema-template default echoes via per-modelreverse_diff_defaultstables to maintain idempotency. - Expand/adjust unit tests to cover removal detection, merged vs replaced behavior, storm-control removal, and default-echo normalization; remove the now-unnecessary PrefixListModel
get_diffoverride.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/module_utils/test_utils.py | Adds unit coverage for issubset and new has_removals behavior, including empty normalization. |
| tests/unit/module_utils/test_nd_state_machine.py | Adds state-machine tests ensuring removal-only diffs update under replaced/overridden but not under merged. |
| tests/unit/module_utils/models/test_storm_control_mutex.py | Adds regression coverage for storm-control removal being detected on the exclude_unset=False path. |
| tests/unit/module_utils/models/test_loopback_interface.py | Updates expectations to reflect fixed removal detection under default (exclude_unset=False) vs merged behavior. |
| tests/unit/module_utils/models/test_base_model_reverse_diff.py | Adds comprehensive tests for reverse-pass removals, scoping, empty/default normalization, and per-policy default tables. |
| plugins/module_utils/utils.py | Introduces _is_effectively_empty and has_removals to detect removals safely for replace-style diffs. |
| plugins/module_utils/models/manage_prefix_list/manage_prefix_list.py | Removes PrefixListModel’s ad-hoc get_diff override now covered by shared reverse-pass logic. |
| plugins/module_utils/models/interfaces/vpc_trunk_host_interface.py | Adds reverse_diff_defaults table for vPC trunk host policy default-echo normalization. |
| plugins/module_utils/models/interfaces/vpc_access_interface.py | Adds reverse_diff_defaults table for vPC access host policy default-echo normalization. |
| plugins/module_utils/models/interfaces/svi_interface.py | Adds reverse_diff_defaults table for SVI policy default-echo normalization. |
| plugins/module_utils/models/interfaces/subinterface_managed_interface.py | Adds reverse_diff_defaults table for managed subinterface policy default-echo normalization. |
| plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py | Adds reverse_diff_defaults table for port-channel trunk host policy default-echo normalization. |
| plugins/module_utils/models/interfaces/port_channel_access_interface.py | Adds reverse_diff_defaults table for port-channel access policy default-echo normalization. |
| plugins/module_utils/models/interfaces/loopback_interface.py | Adds reverse_diff_defaults table for loopback policy (incl. routeMapTag type-drift handling). |
| plugins/module_utils/models/interfaces/ethernet_trunk_host_interface.py | Adds reverse_diff_defaults table for ethernet trunk host policy default-echo normalization. |
| plugins/module_utils/models/interfaces/ethernet_access_interface.py | Adds reverse_diff_defaults table for ethernet access policy default-echo normalization. |
| plugins/module_utils/models/base.py | Implements reverse-pass export/default stripping and integrates has_removals into NDBaseModel.get_diff for replace-style semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
975f337 to
b52003b
Compare
ND injects a `ptp` boolean into every port-channel policy GET even though intPortChannelTrunkHostTemplate declares no such property (vault: interface-get-undocumented-ptp-field). PortChannelTrunkHostPolicyModel is the only interface model that declares `ptp`, so the echo survives from_response and counted as a pending removal in the reverse pass, misclassifying unchanged replaced/overridden port-channels as changed on every run. Strip it via reverse_diff_exclude (peerSwitchId precedent) rather than a reverse_diff_defaults entry of False: the injected value is not constant -- after a fabric-PTP deploy ND rewrites all physical/port-channel records to true fabric-wide, which a False defaults entry would re-break on. Regression tests cover the ptp:false echo, the post-deploy ptp:true rewrite, and that a user-set ptp still forward-diffs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mtarking
left a comment
There was a problem hiding this comment.
Reviewed the reverse-pass diff logic — the core design is correct and well-tested, and I agree with the approach. The forward/reverse separation is clean (has_removals keys off presence only, values stay with the forward issubset pass), stripping default-valued existing keys is semantically justified for replaced/overridden (resetting an already-default field is a genuine no-op), and the dump-reuse efficiency refactor is nicely pinned by a regression test. CI is green and the prior ptp finding is resolved.
My comments are non-blocking. The one theme worth a firm follow-up is the hand-maintained, ND-4.2.1-pinned reverse_diff_defaults tables: the failure modes on a future ND default change are asymmetric and one (silently skipped reset) is a correctness risk with no test to catch it. Runtime-deriving those defaults from the GET-echoes schema would remove that fragility. The rest are small clarifying-comment nits (list non-recursion in has_removals, list-of-empties normalization) plus an ask to file an explicit follow-up to lab-verify the unverified vPC tables.
Overall: approve-with-follow-ups from my side.
…#422 review) - has_removals: state why dict recursion deliberately excludes lists (forward issubset matches list elements bidirectionally, so element divergence is classified changed there; wholly-omitted list keys are caught by presence) - _is_effectively_empty: note lists are only empty-normalized when literally [], unlike the recursive dict branch, and why - NDBaseModel.reverse_diff_defaults: generalize the loopback routeMapTag lesson -- table values must be in the model's dumped form, not the schema-declared form Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYwbU5K6rZCX8ZXQZbmdB
…#422 review) - has_removals: state why dict recursion deliberately excludes lists (forward issubset matches list elements bidirectionally, so element divergence is classified changed there; wholly-omitted list keys are caught by presence) - _is_effectively_empty: note lists are only empty-normalized when literally [], unlike the recursive dict branch, and why - NDBaseModel.reverse_diff_defaults: generalize the loopback routeMapTag lesson -- table values must be in the model's dumped form, not the schema-declared form Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYwbU5K6rZCX8ZXQZbmdB
…422 lab-verify) Lab verification of both vPC reverse_diff_defaults tables against live ND 4.2.1 reads (SITE1, 2026-08-07) found one gap: ND echoes a collapsed allowedVlans: "none" on trunkVpcHost GETs when the user never set it, and the trunk table had no entry for it, so replaced/overridden reported changed on every run (lab-reproduced: replaced never converged; merged unaffected). The fix uses the DUMPED-form per-peer keys peer1AllowedVlans/peer2AllowedVlans, not the collapsed wire key: the write-side dump fans allowed_vlans out per the vpc-interface-peer-vlan-collapse workaround, and the reverse pass scrubs the dumped form. A collapsed-form allowedVlans entry never matches (also lab-reproduced). All other entries in both vPC tables matched the live echoes value-for-value; the access-side accessVlan is NOT echoed when unset, so the access table needs no change. The ND-injected ptp on vPC GETs is undeclared on the vPC models and is dropped by extra="ignore" at parse time (unlike port-channel, where ptp is a declared field and needs reverse_diff_exclude). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0144mmGSPfTMnsb3SVhAPSPM
Review found the interim reverse_diff_exclude={"ptp"} strip silently
defeated replaced/overridden reset semantics for a field the module
documented as writable. Lab probe (2026-08-12, SITE1, ND 4.2.1.10)
settled the ownership question: intPortChannelTrunkHostTemplate declares
no ptp property, and a client-sent ptp is persist-but-inert -- ND stores
and echoes it but the pending CLI is byte-identical to a control created
without it. The option never configured anything; it was a silent no-op
that faked success.
- Remove ptp from PortChannelTrunkHostPolicyModel, the argspec, and the
module docs; the injected GET echo now drops at parse time via
extra="ignore", same as the sibling ethernet/vPC models.
- Drop reverse_diff_exclude and its TODO(4.2.1) marker; a model comment
(vault id interface-get-undocumented-ptp-field) warns against re-adding
the field from wire observation alone.
- Rework the ptp regression tests: idempotency against both the false
echo and the post-fabric-PTP true rewrite now holds via the parse-time
drop; a new test pins ptp out of model_fields and every dump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrxedqAeo5ZpMP9iKM3Gg3
9873648 to
2b766ca
Compare
ND injects a `ptp` boolean into every port-channel policy GET even though intPortChannelTrunkHostTemplate declares no such property (vault: interface-get-undocumented-ptp-field). PortChannelTrunkHostPolicyModel is the only interface model that declares `ptp`, so the echo survives from_response and counted as a pending removal in the reverse pass, misclassifying unchanged replaced/overridden port-channels as changed on every run. Strip it via reverse_diff_exclude (peerSwitchId precedent) rather than a reverse_diff_defaults entry of False: the injected value is not constant -- after a fabric-PTP deploy ND rewrites all physical/port-channel records to true fabric-wide, which a False defaults entry would re-break on. Regression tests cover the ptp:false echo, the post-deploy ptp:true rewrite, and that a user-set ptp still forward-diffs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#422 review) - has_removals: state why dict recursion deliberately excludes lists (forward issubset matches list elements bidirectionally, so element divergence is classified changed there; wholly-omitted list keys are caught by presence) - _is_effectively_empty: note lists are only empty-normalized when literally [], unlike the recursive dict branch, and why - NDBaseModel.reverse_diff_defaults: generalize the loopback routeMapTag lesson -- table values must be in the model's dumped form, not the schema-declared form Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYwbU5K6rZCX8ZXQZbmdB
…422 lab-verify) Lab verification of both vPC reverse_diff_defaults tables against live ND 4.2.1 reads (SITE1, 2026-08-07) found one gap: ND echoes a collapsed allowedVlans: "none" on trunkVpcHost GETs when the user never set it, and the trunk table had no entry for it, so replaced/overridden reported changed on every run (lab-reproduced: replaced never converged; merged unaffected). The fix uses the DUMPED-form per-peer keys peer1AllowedVlans/peer2AllowedVlans, not the collapsed wire key: the write-side dump fans allowed_vlans out per the vpc-interface-peer-vlan-collapse workaround, and the reverse pass scrubs the dumped form. A collapsed-form allowedVlans entry never matches (also lab-reproduced). All other entries in both vPC tables matched the live echoes value-for-value; the access-side accessVlan is NOT echoed when unset, so the access table needs no change. The ND-injected ptp on vPC GETs is undeclared on the vPC models and is dropped by extra="ignore" at parse time (unlike port-channel, where ptp is a declared field and needs reverse_diff_exclude). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0144mmGSPfTMnsb3SVhAPSPM
Review found the interim reverse_diff_exclude={"ptp"} strip silently
defeated replaced/overridden reset semantics for a field the module
documented as writable. Lab probe (2026-08-12, SITE1, ND 4.2.1.10)
settled the ownership question: intPortChannelTrunkHostTemplate declares
no ptp property, and a client-sent ptp is persist-but-inert -- ND stores
and echoes it but the pending CLI is byte-identical to a control created
without it. The option never configured anything; it was a silent no-op
that faked success.
- Remove ptp from PortChannelTrunkHostPolicyModel, the argspec, and the
module docs; the injected GET echo now drops at parse time via
extra="ignore", same as the sibling ethernet/vPC models.
- Drop reverse_diff_exclude and its TODO(4.2.1) marker; a model comment
(vault id interface-get-undocumented-ptp-field) warns against re-adding
the field from wire observation alone.
- Rework the ptp regression tests: idempotency against both the false
echo and the post-fabric-PTP true rewrite now holds via the parse-time
drop; a new test pins ptp out of model_fields and every dump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrxedqAeo5ZpMP9iKM3Gg3
2b766ca to
8117961
Compare
All four states + idempotency for NX-OS (S1-style leaf/BG ports Ethernet1/31-34) and an IOS-XE flow gated on nd_test_xe_switch_ip (configurable interface via nd_test_xe_interface_name). Lab-verified green (failed=0) on SITE1 + ISN, 2026-07-27. - merged.yaml covers create (inherently the trunk->routed mode flip), multi-interface create, update, idempotency, and deploy:false. - replaced.yaml notes the post-#422 rebase item: assert omitted routing_tag is actually cleared once the reverse pass lands. - xe.yaml establishes its baseline with state:replaced (no delete pre-clean) and asserts overridden reports no change with unnamed IOS-XE interfaces in-fabric (merge-only semantics). The XE deleted block is gated off pending a C8000V-safe reset recipe (vault: c8000v-rejects-per-port-mtu). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All four states + idempotency for NX-OS (S1-style leaf/BG ports Ethernet1/31-34) and an IOS-XE flow gated on nd_test_xe_switch_ip (configurable interface via nd_test_xe_interface_name). Lab-verified green (failed=0) on SITE1 + ISN, 2026-07-27. - merged.yaml covers create (inherently the trunk->routed mode flip), multi-interface create, update, idempotency, and deploy:false. - replaced.yaml notes the post-#422 rebase item: assert omitted routing_tag is actually cleared once the reverse pass lands. - xe.yaml establishes its baseline with state:replaced (no delete pre-clean) and asserts overridden reports no change with unnamed IOS-XE interfaces in-fabric (merge-only semantics). The XE deleted block is gated off pending a C8000V-safe reset recipe (vault: c8000v-rejects-per-port-mtu). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8117961 to
2b92791
Compare
ND injects a `ptp` boolean into every port-channel policy GET even though intPortChannelTrunkHostTemplate declares no such property (vault: interface-get-undocumented-ptp-field). PortChannelTrunkHostPolicyModel is the only interface model that declares `ptp`, so the echo survives from_response and counted as a pending removal in the reverse pass, misclassifying unchanged replaced/overridden port-channels as changed on every run. Strip it via reverse_diff_exclude (peerSwitchId precedent) rather than a reverse_diff_defaults entry of False: the injected value is not constant -- after a fabric-PTP deploy ND rewrites all physical/port-channel records to true fabric-wide, which a False defaults entry would re-break on. Regression tests cover the ptp:false echo, the post-deploy ptp:true rewrite, and that a user-set ptp still forward-diffs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#422 review) - has_removals: state why dict recursion deliberately excludes lists (forward issubset matches list elements bidirectionally, so element divergence is classified changed there; wholly-omitted list keys are caught by presence) - _is_effectively_empty: note lists are only empty-normalized when literally [], unlike the recursive dict branch, and why - NDBaseModel.reverse_diff_defaults: generalize the loopback routeMapTag lesson -- table values must be in the model's dumped form, not the schema-declared form Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYwbU5K6rZCX8ZXQZbmdB
…422 lab-verify) Lab verification of both vPC reverse_diff_defaults tables against live ND 4.2.1 reads (SITE1, 2026-08-07) found one gap: ND echoes a collapsed allowedVlans: "none" on trunkVpcHost GETs when the user never set it, and the trunk table had no entry for it, so replaced/overridden reported changed on every run (lab-reproduced: replaced never converged; merged unaffected). The fix uses the DUMPED-form per-peer keys peer1AllowedVlans/peer2AllowedVlans, not the collapsed wire key: the write-side dump fans allowed_vlans out per the vpc-interface-peer-vlan-collapse workaround, and the reverse pass scrubs the dumped form. A collapsed-form allowedVlans entry never matches (also lab-reproduced). All other entries in both vPC tables matched the live echoes value-for-value; the access-side accessVlan is NOT echoed when unset, so the access table needs no change. The ND-injected ptp on vPC GETs is undeclared on the vPC models and is dropped by extra="ignore" at parse time (unlike port-channel, where ptp is a declared field and needs reverse_diff_exclude). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0144mmGSPfTMnsb3SVhAPSPM
Review found the interim reverse_diff_exclude={"ptp"} strip silently
defeated replaced/overridden reset semantics for a field the module
documented as writable. Lab probe (2026-08-12, SITE1, ND 4.2.1.10)
settled the ownership question: intPortChannelTrunkHostTemplate declares
no ptp property, and a client-sent ptp is persist-but-inert -- ND stores
and echoes it but the pending CLI is byte-identical to a control created
without it. The option never configured anything; it was a silent no-op
that faked success.
- Remove ptp from PortChannelTrunkHostPolicyModel, the argspec, and the
module docs; the injected GET echo now drops at parse time via
extra="ignore", same as the sibling ethernet/vPC models.
- Drop reverse_diff_exclude and its TODO(4.2.1) marker; a model comment
(vault id interface-get-undocumented-ptp-field) warns against re-adding
the field from wire observation alone.
- Rework the ptp regression tests: idempotency against both the false
echo and the post-fabric-PTP true rewrite now holds via the parse-time
drop; a new test pins ptp out of model_fields and every dump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrxedqAeo5ZpMP9iKM3Gg3
…d reverse diff (#410) NDBaseModel.get_diff gains a reverse pass on the exclude_unset=False path: after the forward subset check, has_removals() (new pure util) walks the existing side's payload-scoped dump (exclude_from_diff | payload_exclude_fields) and classifies any non-empty field absent from proposed as a difference, so the full-payload PUT that resets omitted fields is actually issued. Empty values ("", [], {}) normalize to absent, generalizing the PrefixListModel description precedent -- its ad-hoc get_diff override is removed. ND echoes the OpenAPI template default for every field the user never set (lab-verified on 4.2.1: ~22 concrete fields on trunkHost; ND injects routeMapTag 12345 even on user-created loopbacks). A naive reverse pass would therefore report changed on every run. New per-model reverse_diff_defaults ClassVars (alias -> template default, schema-sourced via nd-openapi, values in the model's dumped form) let to_reverse_diff_dict() strip default-valued echoes recursively, keeping replaced/overridden idempotent. Tables added for all nine interface policy models. Merged-state semantics are unchanged. Lab verification (SITE1, ND 4.2.1): issue repro passes (replaced omitting storm_control_broadcast_level now reports changed and clears it), and replaced double-runs on nd_interface_ethernet_trunk_host and nd_interface_loopback report changed=false on the second run. Fixes #410 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqAuV2pWYJTno2bfdcNZCm
Three replaced/overridden idempotency bugs in the issue #410 reverse pass, all one mechanism: the reverse diff fired on existing-side data the proposed config can never express, permanently classifying items as changed. - vPC (accessVpcHost/trunkVpcHost): ND echoes the orchestrator-injected peerSwitchId inside the policy block; it is not in the argspec and is injected only at payload-build time. New alias-keyed reverse_diff_exclude ClassVar, applied at each nested model's own level during the reverse scrub, declared as {"peerSwitchId"} on both vPC policy models. - Fabric models (extra="allow"): undeclared server keys retained on the existing side counted as removals. The reverse scrub now drops model_extra keys at every nesting level; extras are argspec-unreachable in proposed config so they can never represent a pending reset. - nd_local_user: ND echoes xLaunch=false, reuseLimitation=0, and timeIntervalLimitation=0 for never-configured options (asserted by the module's integration tests); False/0 are not "effectively empty". Added the schema-sourced reverse_diff_defaults table to LocalUserModel. _strip_reverse_diff_defaults is generalized to _scrub_reverse_diff_dict (exclusions + extras + defaults, then per-model recursion). Guard tests confirm genuine removals are still detected on all three model families. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnSktMFZGRafkf5GbuBwwm
Review finding: the reverse_diff_defaults tables are ND-4.2.1 wire-behavior workarounds (ND echoes schema/template defaults for every field the user never set, including the loopback routeMapTag injection) but carried no TODO(X.Y.Z) <slug> marker, so /nd-workaround-audit could never surface them when the ND release cadence permits re-verification. All ten sites (nine interface policy models + LocalUserModel) now carry: # TODO(4.2.1) get-echoes-schema-defaults-for-unset-fields backed by the new bug-tracker vault note of the same id (resolvable via get_bug_by_id), which documents the defaults-echo behavior across both endpoint families, the falsy-default trap (false/0 are not empty markers), and the dumped-form rule for table values. Comment-only change: no behavior difference, unit suite green, linters clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnSktMFZGRafkf5GbuBwwm
Review efficiency finding: the replaced/overridden path of get_diff ran a second complete recursive model_dump per side for the reverse pass, doubling classification cost for every no-diff item in a query_all inventory (and the cost repeats inside NDOutput's two-directional changed computation). to_reverse_diff_dict is now derived: one to_diff_dict dump, then in-place scoping via the new _apply_reverse_diff_scope (pop top-level payload_exclude_fields aliases, then the recursive exclusions/extras/defaults scrub). get_diff reuses its already-computed forward dumps -- they are dead after the subset check -- so the no-diff replaced path drops from 4 dumps to 2, guarded by a dump-count regression test. Side benefit: subclass to_diff_dict overrides (e.g. the AI-eBGP nxapiHttp pop) now scope the reverse pass too, closing a latent gap the review noted. Deliberately NOT changed: get_diff_collection's two-directional loop. Several models carry payload-excluded fields that still participate in forward diffs (loopback switch_ip, SVI/subinterface oper_data, prefix-list ip_version), so dropping the second direction would alter changed-flag semantics for those fields, not just save work. Left for a semantics-reviewed follow-up if the remaining cost matters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnSktMFZGRafkf5GbuBwwm
ND injects a `ptp` boolean into every port-channel policy GET even though intPortChannelTrunkHostTemplate declares no such property (vault: interface-get-undocumented-ptp-field). PortChannelTrunkHostPolicyModel is the only interface model that declares `ptp`, so the echo survives from_response and counted as a pending removal in the reverse pass, misclassifying unchanged replaced/overridden port-channels as changed on every run. Strip it via reverse_diff_exclude (peerSwitchId precedent) rather than a reverse_diff_defaults entry of False: the injected value is not constant -- after a fabric-PTP deploy ND rewrites all physical/port-channel records to true fabric-wide, which a False defaults entry would re-break on. Regression tests cover the ptp:false echo, the post-deploy ptp:true rewrite, and that a user-set ptp still forward-diffs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#422 review) - has_removals: state why dict recursion deliberately excludes lists (forward issubset matches list elements bidirectionally, so element divergence is classified changed there; wholly-omitted list keys are caught by presence) - _is_effectively_empty: note lists are only empty-normalized when literally [], unlike the recursive dict branch, and why - NDBaseModel.reverse_diff_defaults: generalize the loopback routeMapTag lesson -- table values must be in the model's dumped form, not the schema-declared form Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYwbU5K6rZCX8ZXQZbmdB
…422 lab-verify) Lab verification of both vPC reverse_diff_defaults tables against live ND 4.2.1 reads (SITE1, 2026-08-07) found one gap: ND echoes a collapsed allowedVlans: "none" on trunkVpcHost GETs when the user never set it, and the trunk table had no entry for it, so replaced/overridden reported changed on every run (lab-reproduced: replaced never converged; merged unaffected). The fix uses the DUMPED-form per-peer keys peer1AllowedVlans/peer2AllowedVlans, not the collapsed wire key: the write-side dump fans allowed_vlans out per the vpc-interface-peer-vlan-collapse workaround, and the reverse pass scrubs the dumped form. A collapsed-form allowedVlans entry never matches (also lab-reproduced). All other entries in both vPC tables matched the live echoes value-for-value; the access-side accessVlan is NOT echoed when unset, so the access table needs no change. The ND-injected ptp on vPC GETs is undeclared on the vPC models and is dropped by extra="ignore" at parse time (unlike port-channel, where ptp is a declared field and needs reverse_diff_exclude). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0144mmGSPfTMnsb3SVhAPSPM
Review found the interim reverse_diff_exclude={"ptp"} strip silently
defeated replaced/overridden reset semantics for a field the module
documented as writable. Lab probe (2026-08-12, SITE1, ND 4.2.1.10)
settled the ownership question: intPortChannelTrunkHostTemplate declares
no ptp property, and a client-sent ptp is persist-but-inert -- ND stores
and echoes it but the pending CLI is byte-identical to a control created
without it. The option never configured anything; it was a silent no-op
that faked success.
- Remove ptp from PortChannelTrunkHostPolicyModel, the argspec, and the
module docs; the injected GET echo now drops at parse time via
extra="ignore", same as the sibling ethernet/vPC models.
- Drop reverse_diff_exclude and its TODO(4.2.1) marker; a model comment
(vault id interface-get-undocumented-ptp-field) warns against re-adding
the field from wire observation alone.
- Rework the ptp regression tests: idempotency against both the false
echo and the post-fabric-PTP true rewrite now holds via the parse-time
drop; a new test pins ptp out of model_fields and every dump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrxedqAeo5ZpMP9iKM3Gg3
The 2026-08-12 persist-but-inert probe ran with SITE1 fabric PTP disabled; rendering behavior with fabric PTP deployed is untested. The removal rationale stands either way: the fabric-PTP deploy rewrites stored ptp values fabric-wide regardless of per-interface intent (2026-06-10 lab evidence), so the field is fabric-owned in both regimes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XrxedqAeo5ZpMP9iKM3Gg3
Deployed fabric PTP on SITE1 (ptpVlanId 1000, ToR source SVIs) and re-ran the two-port-channel probe in the enabled-not-deployed and deployed regimes: a client-sent ptp is persist-but-inert in every regime (echoed back, zero pending CLI difference vs control). ND's own PTP CLI lands only on physical interfaces, never under port-channels, and the fabric-wide record rewrite (all stored ptp flipped true, including port-channel500 which received no CLI) reproduced on 4.2.1.10 exactly as observed 2026-06-10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XrxedqAeo5ZpMP9iKM3Gg3
get_diff had no type guard while merge already raises TypeError on a type mismatch. The reverse pass added by this PR calls other._apply_reverse_diff_scope(), which assumes `other` carries the same reverse_diff_* declarations as `self`, so a cross-type comparison is a programming error rather than a "changed" classification. Mirror merge's isinstance(other, type(self)) check and raise on both the merged and replaced/overridden paths. The only Gen-3 caller (NDConfigCollection.get_diff_config) always compares items from the same collection, so no real path is affected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UxSsLRHL3icznqVK3YDff
Related Issue(s)
Fixes #410
Merge order
Proposed Changes
NDBaseModel.get_diffgains a reverse pass on theexclude_unset=False(replaced/overridden) path: after the forward subset check, the new pure utilutils.has_removals()walks the existing side's payload-scoped dump (exclude_from_diff | payload_exclude_fields) and classifies any non-empty field absent from proposed as a difference, so the full-payload PUT that resets omitted fields is actually issued. Merged-state semantics are unchanged."",[],{}) normalize to absent, generalizing thePrefixListModeldescription precedent; its ad-hocget_diffoverride is removed (its tests pass against the inherited behavior).routeMapTag: 12345even on user-created loopbacks). New per-modelreverse_diff_defaultsClassVars (alias → template default, sourced from the ND 4.2.1 OpenAPI template schemas, values in the model's dumped form) letto_reverse_diff_dict()strip default-valued echoes recursively, keeping replaced/overridden runs idempotent. Tables added for all nine interface policy models (loopback, ethernet access/trunkHost, port-channel access/trunkHost, vPC access/trunkHost, SVI, managed subinterface).TrunkVpcHostPolicyModel— the schema defaults forpeer1AllowedVlans/peer2AllowedVlanshave no model fields (per-peer→collapsedaccess_vlanworkaround); vPC families were not lab-verified in this PR.Post-review hardening (three follow-up commits addressing all five findings from Claude's pre-merge code review):
reverse_diff_excludeClassVar, applied at each nested model's own level during the reverse scrub; both vPC policy models exclude the orchestrator-injectedpeerSwitchId(not in the argspec, injected only at payload-build time, echoed by ND on reads).extra="allow"server keys (model_extra) at every nesting level, so undeclared ND GET keys on the fabric models (top-level or nested management) never count as removals.LocalUserModelgains areverse_diff_defaultstable for ND's falsy echoes (xLaunch=false,reuseLimitation=0,timeIntervalLimitation=0— the module's own integration tests assert thesebeforevalues);False/0are real values, not empty markers, so empty-normalization alone could not cover them.LocalUserModel) carryTODO(4.2.1) get-echoes-schema-defaults-for-unset-fields, backed by a new bug-tracker vault note of the same id (resolvable viaget_bug_by_id) documenting the defaults-echo behavior across both the interfaces and localUsers endpoint families, so/nd-workaround-auditcan surface the tables for re-verification against future ND releases.to_reverse_diff_dictnow derives fromto_diff_dictplus in-place scoping, andget_diffreuses its already-computed forward dumps: 2 dumps per no-diff comparison instead of 4, pinned by a dump-count regression test. Side benefit: subclassto_diff_dictoverrides (e.g. the AI-eBGPnxapiHttppop) now scope the reverse pass symmetrically.NDOutput's two-directional changed loop is deliberately unchanged — payload-excluded-but-diff-compared fields (loopbackswitch_ip, SVI/subinterfaceoper_data, prefix-listip_version) rely on the second direction, so removing it would be a semantics change, not an optimization (documented in the commit body).Test Notes
tests/unit/module_utils/test_utils.py(first coverage forissubset+has_removals),tests/unit/module_utils/models/test_base_model_reverse_diff.py(removal detection, empty/default normalization, per-family default tables incl. the verbatim lab-captured trunkHost echo), state-machine replaced/overridden/merged classification tests, and the previously missingexclude_unset=Falsestorm-control case.test_loopback_interface_00620updated: proposed-with-fewer-fields is now a difference by default (the old assertion documented the replaced/overridden states cannot detect field removals (one-way subset diff) #410 bug).model_dumpper side on the no-diff replaced path, plus standaloneto_reverse_diff_dictbehavior parity).ndpytest tests/unit/(nd-dev container machine).no_diff, genuine removals still classifychanged— with no new findings.ndtestsanity passes except the pre-existingaction-plugin-docsfindings onplugins/action/tests/integration/*(untouched by this PR).state: replacedomittingstorm_control_broadcast_level: 80.0reportschanged: true, issues the PUT, and clears the value on ND; replaced double-runs onnd_interface_ethernet_trunk_hostandnd_interface_loopbackreportchanged: falseon the second run.Cisco Nexus Dashboard Version
4.2.1
Related ND API Resource Category
Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01WqAuV2pWYJTno2bfdcNZCm