Skip to content

Detect field removals in replaced/overridden states (one-way subset diff) - #422

Merged
allenrobel merged 11 commits into
developfrom
nd_replaced_overridden_field_removals
Aug 28, 2026
Merged

Detect field removals in replaced/overridden states (one-way subset diff)#422
allenrobel merged 11 commits into
developfrom
nd_replaced_overridden_field_removals

Conversation

@allenrobel

@allenrobel allenrobel commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Related Issue(s)

Fixes #410

Merge order

Proposed Changes

  • NDBaseModel.get_diff gains a reverse pass on the exclude_unset=False (replaced/overridden) path: after the forward subset check, the new pure util utils.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.
  • Empty existing-side values ("", [], {}) normalize to absent, generalizing the PrefixListModel description precedent; its ad-hoc get_diff override is removed (its tests pass against the inherited behavior).
  • Default-echo normalization: ND echoes the OpenAPI template default for every field the user never set (lab-verified: ~22 concrete fields on trunkHost; ND injects routeMapTag: 12345 even on user-created loopbacks). New per-model reverse_diff_defaults ClassVars (alias → template default, sourced from the ND 4.2.1 OpenAPI template schemas, values in the model's dumped form) let to_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).
  • Known residual: TrunkVpcHostPolicyModel — the schema defaults for peer1AllowedVlans/peer2AllowedVlans have no model fields (per-peer→collapsed access_vlan workaround); 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):

  • Commit "Fix reverse-pass idempotency for server-populated existing-side data" — three confirmed bugs, one mechanism: the reverse pass fired on existing-side data the proposed config can never express, permanently classifying items as changed.
    • New alias-keyed reverse_diff_exclude ClassVar, applied at each nested model's own level during the reverse scrub; both vPC policy models exclude the orchestrator-injected peerSwitchId (not in the argspec, injected only at payload-build time, echoed by ND on reads).
    • The reverse scrub drops 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.
    • LocalUserModel gains a reverse_diff_defaults table for ND's falsy echoes (xLaunch=false, reuseLimitation=0, timeIntervalLimitation=0 — the module's own integration tests assert these before values); False/0 are real values, not empty markers, so empty-normalization alone could not cover them.
  • Commit "Add TODO(4.2.1) workaround markers to all reverse_diff_defaults tables" — all ten table sites (nine interface policy models + LocalUserModel) carry TODO(4.2.1) get-echoes-schema-defaults-for-unset-fields, backed by a new bug-tracker vault note of the same id (resolvable via get_bug_by_id) documenting the defaults-echo behavior across both the interfaces and localUsers endpoint families, so /nd-workaround-audit can surface the tables for re-verification against future ND releases.
  • Commit "Derive reverse-pass dicts from the forward dumps (halve get_diff cost)"to_reverse_diff_dict now derives from to_diff_dict plus in-place scoping, and get_diff reuses its already-computed forward dumps: 2 dumps per no-diff comparison instead of 4, pinned 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 symmetrically. NDOutput's two-directional changed loop is deliberately unchanged — payload-excluded-but-diff-compared fields (loopback switch_ip, SVI/subinterface oper_data, prefix-list ip_version) rely on the second direction, so removing it would be a semantics change, not an optimization (documented in the commit body).

Test Notes

  • New unit tests: tests/unit/module_utils/test_utils.py (first coverage for issubset + 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 missing exclude_unset=False storm-control case.
  • test_loopback_interface_00620 updated: 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).
  • Post-review commits add a 005xx test section (per-bug phantom-removal reproductions built TDD-first, each paired with a genuine-removal guard proving the fix does not over-strip) and a 006xx efficiency section (dump-count contract: exactly one model_dump per side on the no-diff replaced path, plus standalone to_reverse_diff_dict behavior parity).
  • Full unit suite passes: 3865 tests via ndpytest tests/unit/ (nd-dev container machine).
  • Review re-verification: the pre-merge code review's five findings (three confirmed idempotency bugs, the missing workaround markers, the redundant reverse dumps) were re-verified fixed at branch HEAD by re-running the original reproduction scripts — phantom changes now classify no_diff, genuine removals still classify changed — with no new findings.
  • black/isort/pylint/mypy clean on changed files; ndtest sanity passes except the pre-existing action-plugin-docs findings on plugins/action/tests/integration/* (untouched by this PR).
  • Lab verification (SITE1): the issue's repro passes — state: replaced omitting storm_control_broadcast_level: 80.0 reports changed: true, issues the PUT, and clears the value on ND; replaced double-runs on nd_interface_ethernet_trunk_host and nd_interface_loopback report changed: false on the second run.

Cisco Nexus Dashboard Version

4.2.1

Related ND API Resource Category

  • analyze
  • infra
  • manage
  • onemanage
  • other

Checklist

  • Latest commit is rebased from develop with merge conflicts resolved
  • New or updates to documentation has been made accordingly
  • Assigned the proper reviewers

🤖 Generated with Claude Code

https://claude.ai/code/session_01WqAuV2pWYJTno2bfdcNZCm

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 into NDBaseModel.get_diff for exclude_unset=False to correctly trigger updates on field removals.
  • Normalize ND “empty marker” echoes ("", [], {}, nested-empty dicts) and strip schema-template default echoes via per-model reverse_diff_defaults tables 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_diff override.

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.

allenrobel added a commit that referenced this pull request Jul 30, 2026
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>
@allenrobel
allenrobel requested a review from mikewiebe July 30, 2026 18:47

@mtarking mtarking left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread plugins/module_utils/models/base.py
Comment thread plugins/module_utils/utils.py
Comment thread plugins/module_utils/utils.py
Comment thread plugins/module_utils/models/interfaces/loopback_interface.py
Comment thread plugins/module_utils/models/interfaces/vpc_trunk_host_interface.py
allenrobel added a commit that referenced this pull request Aug 7, 2026
…#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
allenrobel added a commit that referenced this pull request Aug 22, 2026
…#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
allenrobel added a commit that referenced this pull request Aug 22, 2026
…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
allenrobel added a commit that referenced this pull request Aug 22, 2026
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
@allenrobel
allenrobel force-pushed the nd_replaced_overridden_field_removals branch from 9873648 to 2b766ca Compare August 22, 2026 23:01
allenrobel added a commit that referenced this pull request Aug 24, 2026
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>
allenrobel added a commit that referenced this pull request Aug 24, 2026
…#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
allenrobel added a commit that referenced this pull request Aug 24, 2026
…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
allenrobel added a commit that referenced this pull request Aug 24, 2026
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
@allenrobel
allenrobel force-pushed the nd_replaced_overridden_field_removals branch from 2b766ca to 8117961 Compare August 24, 2026 23:00
allenrobel added a commit that referenced this pull request Aug 24, 2026
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>
allenrobel added a commit that referenced this pull request Aug 25, 2026
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>
@allenrobel
allenrobel force-pushed the nd_replaced_overridden_field_removals branch from 8117961 to 2b92791 Compare August 25, 2026 03:13
allenrobel added a commit that referenced this pull request Aug 25, 2026
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>
allenrobel added a commit that referenced this pull request Aug 25, 2026
…#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
allenrobel added a commit that referenced this pull request Aug 25, 2026
…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
allenrobel added a commit that referenced this pull request Aug 25, 2026
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
Comment thread plugins/module_utils/models/base.py
Comment thread plugins/module_utils/models/interfaces/ethernet_access_interface.py
allenrobel and others added 11 commits August 27, 2026 10:35
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

nac01 NaC ND release 0.0.1 ready for review Submitter is requesting a PR review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

replaced/overridden states cannot detect field removals (one-way subset diff)

6 participants