Detect field removals in replaced/overridden states (one-way subset diff) - #422
Detect field removals in replaced/overridden states (one-way subset diff)#422allenrobel wants to merge 10 commits into
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 4.2.1 `int_port_channel_trunk_host` template defaults (schema-sourced via nd-openapi `intPortChannelTrunkHostTemplate`). ND echoes these | ||
| # for every field the user never set; the reverse pass of `get_diff` normalizes existing-side matches to absent | ||
| # so replaced/overridden removal detection (issue #410) stays idempotent against default echoes. | ||
| reverse_diff_defaults: ClassVar[dict[str, Any]] = { |
There was a problem hiding this comment.
High: Missing ptp: false default breaks port-channel idempotency
Issue
The new reverse-diff normalization omits Nexus Dashboard's normal ptp: false
echo from the port-channel trunk-host default table, so unchanged
replaced/overridden configurations are classified as changed.
Evidence
port_channel_trunk_host_interface.pyline 237
addsreverse_diff_defaultsfor the controller's normal schema defaults but
omitsptp.port_channel_trunk_host_interface.pyline 302
declaresptpas a user-configurable policy field, and normal Nexus
Dashboard responses populate it asfalsewhen the user did not set it.- An exact-head reproduction using the existing controller-response fixture
retained{"ptp": false}in the reverse dictionary and reported a
difference against an otherwise unchanged desired configuration.
Practical example
Assume this port-channel is already configured correctly and PTP was never
enabled:
- name: Maintain the server trunk
cisco.nd.nd_interface_port_channel_trunk_host:
fabric_name: FABRIC1
state: replaced
config:
- switch_ip: 192.0.2.11
interface_name: port-channel10
config_data:
network_os:
policy:
admin_state: true
allowed_vlans: "100-110"
ports:
- Ethernet1/1
- Ethernet1/2
# ptp is intentionally omittedNexus Dashboard returns the existing policy with schema defaults populated:
adminState: true
allowedVlans: "100-110"
ports:
- Ethernet1/1
- Ethernet1/2
ptp: falseThe desired and actual PTP state are effectively identical: PTP is disabled.
However, the new reverse comparison retains ptp: false because that normal
controller echo is missing from reverse_diff_defaults.
| Run | Actual configuration | Module result | Controller actions |
|---|---|---|---|
| 1 | Already correct; PTP disabled | changed: true |
PUT and deploy |
| 2 | Still correct; PTP disabled | changed: true |
Same PUT and deploy |
| 3 | Still correct; PTP disabled | changed: true |
Same PUT and deploy |
The controller continues returning ptp: false, so every run detects the same
false difference.
Existing PR overlap
No matching existing PR comment found. PR #422 currently has no inline or
top-level conversation comments identifying this port-channel default.
Existing open issue overlap
No matching open issue found. Issue
#410 tracks the broader
one-way removal-detection defect that PR #422 implements; it does not track
this missing-default regression in the proposed fix.
Impact
An idempotent replaced or overridden task can report changed=True, issue a
redundant PUT, and deploy the same port-channel on every playbook run. Across
many port-channels, this produces unnecessary controller and switch churn and
makes change reporting unreliable.
Suggested fix
Add "ptp": False to
PortChannelTrunkHostPolicyModel.reverse_diff_defaults. Add a regression test
built from a normal controller response containing ptp: false, with desired
configuration omitting PTP, and assert that both replaced and overridden
remain unchanged and schedule no PUT or deployment.
There was a problem hiding this comment.
Confirmed — the reverse pass retained the ND-injected ptp and misclassified unchanged port-channels as changed. Fixed in "Strip ND-injected ptp from the port-channel reverse pass", but via reverse_diff_exclude rather than the defaults table, for two reasons: (1) intPortChannelTrunkHostTemplate declares no ptp property, and the reverse_diff_defaults tables are schema-sourced (slug get-echoes-schema-defaults-for-unset-fields) — this injection is the sibling deviation interface-get-undocumented-ptp-field; (2) the injected value isn't constant: after a fabric-PTP deploy, ND rewrites all physical/port-channel records to ptp: true fabric-wide (lab-verified, see the vault note), so normalizing only false would re-break idempotency there. The unconditional strip follows the peerSwitchId precedent on the vPC models. Regression tests cover the ptp: false echo, the post-deploy ptp: true rewrite, and that a user-set ptp still forward-diffs.
There was a problem hiding this comment.
High: Unconditional ptp exclusion silently defeats replaced/overridden removal
Issue
The fix for ND's injected ptp echo unconditionally removes ptp from the
reverse comparison even though the module still exposes it as a writable
port-channel field. A replaced or overridden task that omits a previously
enabled PTP value is classified as unchanged, so the requested reset is never
sent.
Evidence
-
port_channel_trunk_host_interface.pylines 264-270
addsreverse_diff_exclude = {"ptp"}, stripping eitherfalseortrue
from removal detection. -
The same model still declares
ptpas “Enable Precision Time Protocol on
the interface” at
line 310,
includes it in the argspec, and the module documents it as writable at
nd_interface_port_channel_trunk_host.pyline 195. -
The current regression at
test_base_model_reverse_diff.pylines 708-730
deliberately asserts that existingptp: trueplus a proposed replacement
omittingptpis “no difference.” Test 00580 proves only that an explicitly
supplied opposite value still forward-diffs. -
Exact-head probe:
existing response: ptp=true proposed replaced config: ptp omitted get_diff(..., exclude_unset=False): True # interpreted as no_diff -
A practical two-run workflow demonstrates the user-visible failure. First,
the user enables PTP through the documented field:- name: Enable PTP on the server port-channel cisco.nd.nd_interface_port_channel_trunk_host: fabric_name: FABRIC1 state: replaced config: - switch_ip: 192.0.2.11 interface_name: port-channel10 config_data: network_os: policy: admin_state: true allowed_vlans: "100-110" ports: - Ethernet1/1 - Ethernet1/2 ptp: true
Later, the user relies on
replacedsemantics and omitsptpto reset it:- name: Replace the policy and reset omitted settings cisco.nd.nd_interface_port_channel_trunk_host: fabric_name: FABRIC1 state: replaced config: - switch_ip: 192.0.2.11 interface_name: port-channel10 config_data: network_os: policy: admin_state: true allowed_vlans: "100-110" ports: - Ethernet1/1 - Ethernet1/2 # ptp omitted: reset it to the default
Outcome Expected Current PR behavior Module result changed: truechanged: falseController action PUT resetting PTP No PUT Final PTP state Disabled/defaulted Remains trueThe forward subset test passes because desired omits
ptp. The reverse pass
should detect the existing-only field, butreverse_diff_excluderemoves it,
so the state machine reports no difference and never sends the reset.
Existing PR overlap
Related to @mikewiebe's active
thread r3681578271.
That thread identified the original ptp: false false-positive loop. The
current finding shows that the unconditional-exclusion repair creates the
inverse false negative. The thread is not close-ready.
Existing open issue overlap
Related open issues: #410
broadly tracks omitted-field false negatives for replaced/overridden, but
does not resolve the new writable-field versus controller-owned ptp contract
conflict introduced by this repair. #497
tracks schema-derived default tables and explicitly leaves undeclared ptp
handling to reverse_diff_exclude; it does not track this conflict either.
Impact
A documented configuration field can remain enabled after a replacement says
to remove omitted settings. This is a silent convergence failure: Ansible
reports no change and never sends the reset. The author's lab evidence that ND
rewrites ptp fabric-wide explains why a simple False default is
insufficient, but it also demonstrates that the ownership contract must be
resolved rather than hiding the field in only one comparison direction.
Suggested fix
Choose one consistent contract. If ptp is controller/fabric-owned telemetry,
remove it from the model's writable fields, argspec, documentation, and payload.
If it is user-writable, preserve real replaced/ overridden reset
semantics while separately normalizing controller-injected fabric state. Add
tests for omitted reset, explicit true/false transitions, and both observed GET
echoes.
There was a problem hiding this comment.
Confirmed — the exclusion traded the false-positive loop for the inverse false negative, exactly as described. The contract conflict is now resolved by removing ptp from the writable surface entirely (commit "Remove inert ptp field from port-channel trunk-host (#422 review)"), your first option. Two findings drove that choice over preserving reset semantics:
intPortChannelTrunkHostTemplatedeclares noptpproperty (nor doesintTrunkHostTemplate— no non-IPFM template does).- Lab probe, ND 4.2.1.10 (2026-08-12, intent-only, fabric PTP disabled): two trunkPoHost port-channels created identically except one sent
ptp: true. ND persisted and echoed the sent value, butGET .../pendingConfigshowed byte-identical generated CLI for both — no PTP command. On a non-PTP fabric a user-setptpwas persist-but-inert: it configured nothing while the echo faked success. Rendering with fabric PTP deployed is untested, but our 2026-06-10 lab evidence shows the fabric-PTP deploy rewrites the stored value fabric-wide regardless of per-interface intent — so per-interfaceptpcannot durably express user intent in either regime, and there was no working "reset" to preserve.
The injected GET echo now drops at parse time (extra="ignore"), matching the sibling ethernet/vPC models; reverse_diff_exclude is gone, so no writable field is hidden from removal detection. Since the module has not shipped, the option removal has no user impact. Tests cover idempotency against both the ptp: false echo and the post-fabric-PTP true rewrite, plus a pin that ptp stays out of model_fields and every dump. The bug-tracker vault note (interface-get-undocumented-ptp-field) is updated with the probe evidence.
There was a problem hiding this comment.
Follow-up closing the caveat: we deployed fabric PTP on SITE1 (ptpVlanId: 1000, ToR source SVIs, feature ptp live on all 7 switches) and re-ran the probe in the enabled-not-deployed and fully-deployed regimes. A client-sent ptp is persist-but-inert in all three regimes — echoed back, zero pending-CLI difference vs an identical control. Two structural confirmations from the deploy: ND's generated PTP CLI lands only on physical interfaces, never under port-channels (matching NX-OS's per-physical-port PTP model, so a port-channel-level ptp knob cannot render anything), and the fabric-wide record rewrite reproduced on 4.2.1.10 — every pre-deploy physical/port-channel record now stores ptp: true including port-channel500, which received no PTP CLI. The removal rationale is now unconditional; code comments and the vault note are updated (commit "Close the ptp probe caveat: inert in all three fabric-PTP regimes").
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.
| continue | ||
| if key not in proposed_data: | ||
| return True | ||
| if isinstance(value, dict) and has_removals(value, proposed_data[key]): |
There was a problem hiding this comment.
has_removals recurses into nested dicts present on both sides but not into lists, so a removal expressed inside a list element (e.g. a nested dict in a policy/ports list that loses a key) won't be detected by the reverse pass. In practice the forward issubset catches most list divergences, so this is likely fine — but the dict-vs-list asymmetry is surprising. Worth a one-line comment here so a future reader doesn't assume deep list coverage.
There was a problem hiding this comment.
Comment added at the recursion site in "Document reverse-pass list handling and dumped-form table requirement". Worth stating here too: the dict-vs-list asymmetry is benign by construction, not just "likely fine" — the forward issubset pass matches list elements bidirectionally (issubset(item, candidate) and issubset(candidate, item)), so a list element that loses a key fails the forward pass and classifies as changed; a wholly-omitted list key is caught by the key-presence check in has_removals. List coverage is complete across the two passes — it just wasn't visible at this call site, which is what the new comment fixes.
There was a problem hiding this comment.
Medium: Reverse scrubbing skips models inside lists
Issue
_scrub_reverse_diff_dict removes controller-only extras from the current model and directly nested models, but it does not recurse into model instances inside lists, tuples, or dictionaries. A controller-added key in a nested list can therefore make an otherwise identical replaced or overridden resource appear changed forever.
Evidence
-
base.pylines 304–311 recurses only when the direct field value is anNDBaseModel. -
utils.pylines 50–62 requires list elements to match in both directions, so a server-only key fails the forward comparison before reverse scrubbing occurs. -
NetflowExporterModelpermits response extras and is held innetflowExporterCollection: list[NetflowExporterModel]atmanage_fabric_common.pylines 110–127. -
The current-head probe retains the controller-only field:
existing: {'children': [{'name': 'same', 'controllerOnly': 'x'}]} reverse: {'children': [{'name': 'same', 'controllerOnly': 'x'}]} get_diff: False # interpreted as changed
Practical example:
# Desired configuration
netflowExporterCollection:
- exporterName: COLLECTOR1
exporterIp: 192.0.2.50
vrf: management
sourceInterfaceName: loopback0
udpPort: 2055ND returns the same configuration with a generated identifier:
netflowExporterCollection:
- exporterName: COLLECTOR1
exporterIp: 192.0.2.50
vrf: management
sourceInterfaceName: loopback0
udpPort: 2055
controllerGeneratedId: exporter-74Although every user-managed value matches, the list item still contains controllerGeneratedId during comparison. The module consequently reports changed: true and can repeat the same PUT/deploy on every run.
Existing PR overlap
Related to @mtarking’s active thread r3731410339. That thread discusses list recursion, and the reply claims the forward and reverse passes provide complete list coverage. This counterexample shows that user-field removals are covered, but controller-only extras inside list-contained models are not. The thread is not close-ready.
Existing open issue overlap
Related open issue #450 broadly tracks nested-resource idempotency when controller responses contain additional fields, primarily for merged nested resources. It does not identify this PR’s forward-before-scrub failure on the replaced/overridden path. #386 concerns nested-list merge semantics and is a different operation.
Impact
Any shared Gen-3 model that allows response extras in child models held by a container can repeatedly issue writes and deployments for an unchanged resource. Check mode also predicts a false change.
Suggested fix
Recursively scrub NDBaseModel instances inside lists, tuples, and dictionary values before the forward comparison, while preserving container shape and ordering. Add a NetFlow-exporter regression plus a control proving a real user-managed list change is still detected.
There was a problem hiding this comment.
Confirmed with the real models — existing NetflowExporterModel list item carrying a controller-only key vs. an identical proposed item: forward issubset returns False before any scrub runs, permanent changed=true. Your counterexample also corrects my earlier reply: "complete list coverage" holds for user-field removals, not for controller extras inside list items.
One scoping fact: the failure is in the forward pass's bidirectional list-element match (issubset(item, candidate) and issubset(candidate, item)), and issubset is byte-identical to develop — this PR neither introduced nor touched it. The same false-changed fires on develop today, on the merged path as well; the reverse scrub this PR adds runs only after a forward match, so it cannot reach the defect (which is also why recursing the scrub into lists would not fix the user-visible symptom). Rather than grow #422 into forward-diff semantics, I have posted the counterexample and a proposed fix direction to #450 (nested-resource idempotency when controller responses carry additional fields): #450 (comment). Happy to pick it up there as a fast-follow.
…#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
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
3eb7052 to
dce04e8
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>
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
dce04e8 to
9eab6c5
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>
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
9eab6c5 to
69baaab
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>
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
d7450c4 to
9873648
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>
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