wxcli 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (413) hide show
  1. wxcli/__init__.py +4 -0
  2. wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
  3. wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
  4. wxcli/_playbook/.claude/rules/cleanup.md +29 -0
  5. wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
  6. wxcli/_playbook/.claude/rules/org-health.md +14 -0
  7. wxcli/_playbook/.claude/settings.json +8 -0
  8. wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
  9. wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
  10. wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
  11. wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
  12. wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
  13. wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
  14. wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
  15. wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
  16. wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
  17. wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
  18. wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
  19. wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
  20. wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
  21. wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
  22. wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
  23. wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
  24. wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
  25. wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
  26. wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
  27. wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
  28. wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
  29. wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
  30. wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
  31. wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
  32. wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
  33. wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
  34. wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
  35. wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
  36. wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
  37. wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
  38. wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
  39. wxcli/_playbook/CLAUDE.md +373 -0
  40. wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
  41. wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
  42. wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
  43. wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
  44. wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
  45. wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
  46. wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
  47. wxcli/_playbook/docs/reference/admin-partner.md +357 -0
  48. wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
  49. wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
  50. wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
  51. wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
  52. wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
  53. wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
  54. wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
  55. wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
  56. wxcli/_playbook/docs/reference/authentication.md +965 -0
  57. wxcli/_playbook/docs/reference/call-control.md +1205 -0
  58. wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
  59. wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
  60. wxcli/_playbook/docs/reference/call-routing.md +2304 -0
  61. wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
  62. wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
  63. wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
  64. wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
  65. wxcli/_playbook/docs/reference/devices-core.md +1869 -0
  66. wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
  67. wxcli/_playbook/docs/reference/devices-platform.md +467 -0
  68. wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
  69. wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
  70. wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
  71. wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
  72. wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
  73. wxcli/_playbook/docs/reference/meetings-content.md +473 -0
  74. wxcli/_playbook/docs/reference/meetings-core.md +903 -0
  75. wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
  76. wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
  77. wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
  78. wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
  79. wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
  80. wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
  81. wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
  82. wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
  83. wxcli/_playbook/docs/reference/provisioning.md +1366 -0
  84. wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
  85. wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
  86. wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
  87. wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
  88. wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
  89. wxcli/_version.py +24 -0
  90. wxcli/auth.py +137 -0
  91. wxcli/commands/__init__.py +0 -0
  92. wxcli/commands/_registry.py +181 -0
  93. wxcli/commands/activation_email.py +103 -0
  94. wxcli/commands/admin_recordings.py +585 -0
  95. wxcli/commands/analytics.py +105 -0
  96. wxcli/commands/announcement_playlists.py +219 -0
  97. wxcli/commands/announcements.py +519 -0
  98. wxcli/commands/archive_users.py +36 -0
  99. wxcli/commands/attachment_actions.py +60 -0
  100. wxcli/commands/audit_events.py +83 -0
  101. wxcli/commands/authorizations.py +113 -0
  102. wxcli/commands/auto_attendant.py +642 -0
  103. wxcli/commands/broadworks_billing_reports.py +126 -0
  104. wxcli/commands/broadworks_enterprises.py +160 -0
  105. wxcli/commands/broadworks_subscribers.py +319 -0
  106. wxcli/commands/broadworks_workspaces.py +108 -0
  107. wxcli/commands/call_controls.py +1304 -0
  108. wxcli/commands/call_park.py +494 -0
  109. wxcli/commands/call_pickup.py +229 -0
  110. wxcli/commands/call_queue.py +1878 -0
  111. wxcli/commands/call_recording.py +707 -0
  112. wxcli/commands/call_routing.py +1610 -0
  113. wxcli/commands/call_settings_for_me_phase_5.py +212 -0
  114. wxcli/commands/caller_reputation.py +169 -0
  115. wxcli/commands/calling_service.py +305 -0
  116. wxcli/commands/cc_address_book.py +770 -0
  117. wxcli/commands/cc_agent_greetings.py +242 -0
  118. wxcli/commands/cc_agent_summaries.py +39 -0
  119. wxcli/commands/cc_agent_wellbeing.py +216 -0
  120. wxcli/commands/cc_agents.py +425 -0
  121. wxcli/commands/cc_ai_assistant.py +55 -0
  122. wxcli/commands/cc_ai_feature.py +294 -0
  123. wxcli/commands/cc_audio_files.py +149 -0
  124. wxcli/commands/cc_auto_csat.py +311 -0
  125. wxcli/commands/cc_aux_code.py +458 -0
  126. wxcli/commands/cc_business_hour.py +353 -0
  127. wxcli/commands/cc_call_monitoring.py +228 -0
  128. wxcli/commands/cc_callbacks.py +194 -0
  129. wxcli/commands/cc_campaign.py +212 -0
  130. wxcli/commands/cc_captures.py +39 -0
  131. wxcli/commands/cc_contact_list.py +169 -0
  132. wxcli/commands/cc_contact_number.py +295 -0
  133. wxcli/commands/cc_data_sources.py +235 -0
  134. wxcli/commands/cc_desktop_layout.py +380 -0
  135. wxcli/commands/cc_desktop_profile.py +322 -0
  136. wxcli/commands/cc_dial_number.py +511 -0
  137. wxcli/commands/cc_dial_plan.py +347 -0
  138. wxcli/commands/cc_dnc.py +101 -0
  139. wxcli/commands/cc_entry_point.py +545 -0
  140. wxcli/commands/cc_ewt.py +51 -0
  141. wxcli/commands/cc_flow.py +477 -0
  142. wxcli/commands/cc_global_vars.py +430 -0
  143. wxcli/commands/cc_holiday_list.py +335 -0
  144. wxcli/commands/cc_journey.py +1261 -0
  145. wxcli/commands/cc_legacy_flows.py +49 -0
  146. wxcli/commands/cc_multimedia_profile.py +436 -0
  147. wxcli/commands/cc_notification.py +51 -0
  148. wxcli/commands/cc_outdial_ani.py +604 -0
  149. wxcli/commands/cc_overrides.py +341 -0
  150. wxcli/commands/cc_queue.py +1083 -0
  151. wxcli/commands/cc_queue_stats.py +55 -0
  152. wxcli/commands/cc_realtime.py +51 -0
  153. wxcli/commands/cc_resource_collection.py +312 -0
  154. wxcli/commands/cc_search.py +46 -0
  155. wxcli/commands/cc_site.py +400 -0
  156. wxcli/commands/cc_skill.py +350 -0
  157. wxcli/commands/cc_skill_profile.py +289 -0
  158. wxcli/commands/cc_subscriptions.py +426 -0
  159. wxcli/commands/cc_summaries.py +130 -0
  160. wxcli/commands/cc_tasks.py +844 -0
  161. wxcli/commands/cc_team.py +325 -0
  162. wxcli/commands/cc_user_profiles.py +349 -0
  163. wxcli/commands/cc_users.py +701 -0
  164. wxcli/commands/cc_work_types.py +363 -0
  165. wxcli/commands/cdr.py +89 -0
  166. wxcli/commands/classifications.py +38 -0
  167. wxcli/commands/cleanup.py +1427 -0
  168. wxcli/commands/client_settings.py +72 -0
  169. wxcli/commands/conference.py +328 -0
  170. wxcli/commands/configure.py +72 -0
  171. wxcli/commands/converged_recordings.py +347 -0
  172. wxcli/commands/converged_recordings_export.py +310 -0
  173. wxcli/commands/cq_playlists.py +42 -0
  174. wxcli/commands/cucm.py +2794 -0
  175. wxcli/commands/cucm_config.py +129 -0
  176. wxcli/commands/customer_assist.py +373 -0
  177. wxcli/commands/data_sources.py +227 -0
  178. wxcli/commands/dect_devices.py +787 -0
  179. wxcli/commands/device_configurations.py +77 -0
  180. wxcli/commands/device_dynamic_settings.py +320 -0
  181. wxcli/commands/device_settings.py +1489 -0
  182. wxcli/commands/devices.py +280 -0
  183. wxcli/commands/domains.py +142 -0
  184. wxcli/commands/ecm.py +178 -0
  185. wxcli/commands/emergency_services.py +827 -0
  186. wxcli/commands/events.py +83 -0
  187. wxcli/commands/external_voicemail.py +52 -0
  188. wxcli/commands/groups.py +206 -0
  189. wxcli/commands/guest_management.py +76 -0
  190. wxcli/commands/hds.py +340 -0
  191. wxcli/commands/hot_desk.py +67 -0
  192. wxcli/commands/hot_desking_members.py +118 -0
  193. wxcli/commands/hot_desking_portal.py +124 -0
  194. wxcli/commands/hunt_group.py +577 -0
  195. wxcli/commands/hybrid_clusters.py +71 -0
  196. wxcli/commands/hybrid_connectors.py +67 -0
  197. wxcli/commands/identity_org.py +159 -0
  198. wxcli/commands/init_playbook.py +155 -0
  199. wxcli/commands/licenses.py +106 -0
  200. wxcli/commands/live_monitoring.py +40 -0
  201. wxcli/commands/location_call_handling.py +577 -0
  202. wxcli/commands/location_schedules.py +341 -0
  203. wxcli/commands/location_settings.py +1297 -0
  204. wxcli/commands/location_voicemail.py +494 -0
  205. wxcli/commands/locations.py +336 -0
  206. wxcli/commands/meeting_captions.py +110 -0
  207. wxcli/commands/meeting_chats.py +63 -0
  208. wxcli/commands/meeting_invitees.py +234 -0
  209. wxcli/commands/meeting_messages.py +28 -0
  210. wxcli/commands/meeting_participants.py +278 -0
  211. wxcli/commands/meeting_polls.py +111 -0
  212. wxcli/commands/meeting_preferences.py +515 -0
  213. wxcli/commands/meeting_qa.py +78 -0
  214. wxcli/commands/meeting_qualities.py +43 -0
  215. wxcli/commands/meeting_reports.py +101 -0
  216. wxcli/commands/meeting_session_types.py +105 -0
  217. wxcli/commands/meeting_site.py +57 -0
  218. wxcli/commands/meeting_slido.py +44 -0
  219. wxcli/commands/meeting_summaries.py +92 -0
  220. wxcli/commands/meeting_tracking_codes.py +233 -0
  221. wxcli/commands/meeting_transcripts.py +230 -0
  222. wxcli/commands/meetings.py +1997 -0
  223. wxcli/commands/memberships.py +164 -0
  224. wxcli/commands/messages.py +213 -0
  225. wxcli/commands/mode_management.py +244 -0
  226. wxcli/commands/my_call_settings.py +3577 -0
  227. wxcli/commands/numbers.py +405 -0
  228. wxcli/commands/operating_modes.py +410 -0
  229. wxcli/commands/org_contacts.py +284 -0
  230. wxcli/commands/org_health_cli.py +55 -0
  231. wxcli/commands/org_settings.py +71 -0
  232. wxcli/commands/organizations.py +83 -0
  233. wxcli/commands/paging_group.py +257 -0
  234. wxcli/commands/partner_admins.py +141 -0
  235. wxcli/commands/partner_reports.py +188 -0
  236. wxcli/commands/partner_tags.py +216 -0
  237. wxcli/commands/people.py +279 -0
  238. wxcli/commands/person_call_settings.py +71 -0
  239. wxcli/commands/pstn.py +280 -0
  240. wxcli/commands/recording_report.py +154 -0
  241. wxcli/commands/report_templates.py +38 -0
  242. wxcli/commands/reports.py +144 -0
  243. wxcli/commands/resource_group_memberships.py +160 -0
  244. wxcli/commands/resource_groups.py +67 -0
  245. wxcli/commands/roles.py +63 -0
  246. wxcli/commands/room_tabs.py +160 -0
  247. wxcli/commands/rooms.py +225 -0
  248. wxcli/commands/scim_bulk.py +46 -0
  249. wxcli/commands/scim_groups.py +246 -0
  250. wxcli/commands/scim_schemas.py +82 -0
  251. wxcli/commands/scim_users.py +283 -0
  252. wxcli/commands/security_audit.py +54 -0
  253. wxcli/commands/service_apps.py +51 -0
  254. wxcli/commands/single_number_reach.py +209 -0
  255. wxcli/commands/team_memberships.py +155 -0
  256. wxcli/commands/teams.py +153 -0
  257. wxcli/commands/ucm_profile.py +42 -0
  258. wxcli/commands/update.py +219 -0
  259. wxcli/commands/user_settings.py +5116 -0
  260. wxcli/commands/video_mesh.py +1090 -0
  261. wxcli/commands/virtual_extensions.py +502 -0
  262. wxcli/commands/virtual_line_settings.py +2072 -0
  263. wxcli/commands/webhooks.py +176 -0
  264. wxcli/commands/wholesale_billing_reports.py +147 -0
  265. wxcli/commands/wholesale_provisioning.py +523 -0
  266. wxcli/commands/workspace_locations.py +339 -0
  267. wxcli/commands/workspace_metrics.py +103 -0
  268. wxcli/commands/workspace_personalization.py +70 -0
  269. wxcli/commands/workspace_settings.py +3347 -0
  270. wxcli/commands/workspaces.py +279 -0
  271. wxcli/commands/xapi.py +114 -0
  272. wxcli/config.py +139 -0
  273. wxcli/errors.py +77 -0
  274. wxcli/main.py +210 -0
  275. wxcli/migration/CLAUDE.md +106 -0
  276. wxcli/migration/__init__.py +1 -0
  277. wxcli/migration/advisory/CLAUDE.md +196 -0
  278. wxcli/migration/advisory/__init__.py +46 -0
  279. wxcli/migration/advisory/advisor.py +107 -0
  280. wxcli/migration/advisory/advisory_patterns.py +2552 -0
  281. wxcli/migration/advisory/recommendation_rules.py +835 -0
  282. wxcli/migration/cucm/CLAUDE.md +74 -0
  283. wxcli/migration/cucm/__init__.py +5 -0
  284. wxcli/migration/cucm/connection.py +265 -0
  285. wxcli/migration/cucm/discovery.py +225 -0
  286. wxcli/migration/cucm/extractors/__init__.py +1 -0
  287. wxcli/migration/cucm/extractors/announcements.py +92 -0
  288. wxcli/migration/cucm/extractors/base.py +90 -0
  289. wxcli/migration/cucm/extractors/device_profiles.py +98 -0
  290. wxcli/migration/cucm/extractors/devices.py +193 -0
  291. wxcli/migration/cucm/extractors/e911.py +103 -0
  292. wxcli/migration/cucm/extractors/features.py +309 -0
  293. wxcli/migration/cucm/extractors/helpers.py +73 -0
  294. wxcli/migration/cucm/extractors/informational.py +295 -0
  295. wxcli/migration/cucm/extractors/locations.py +221 -0
  296. wxcli/migration/cucm/extractors/moh.py +100 -0
  297. wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
  298. wxcli/migration/cucm/extractors/routing.py +493 -0
  299. wxcli/migration/cucm/extractors/shared_lines.py +146 -0
  300. wxcli/migration/cucm/extractors/templates.py +229 -0
  301. wxcli/migration/cucm/extractors/tier4.py +173 -0
  302. wxcli/migration/cucm/extractors/users.py +240 -0
  303. wxcli/migration/cucm/extractors/voicemail.py +243 -0
  304. wxcli/migration/cucm/extractors/workspaces.py +88 -0
  305. wxcli/migration/cucm/unity_connection.py +304 -0
  306. wxcli/migration/execute/CLAUDE.md +372 -0
  307. wxcli/migration/execute/__init__.py +303 -0
  308. wxcli/migration/execute/batch.py +277 -0
  309. wxcli/migration/execute/dependency.py +483 -0
  310. wxcli/migration/execute/engine.py +964 -0
  311. wxcli/migration/execute/handlers.py +2232 -0
  312. wxcli/migration/execute/planner.py +1939 -0
  313. wxcli/migration/execute/runtime.py +571 -0
  314. wxcli/migration/export/CLAUDE.md +49 -0
  315. wxcli/migration/export/__init__.py +8 -0
  316. wxcli/migration/export/csv_export.py +126 -0
  317. wxcli/migration/export/deployment_plan.py +479 -0
  318. wxcli/migration/export/json_export.py +60 -0
  319. wxcli/migration/models.py +941 -0
  320. wxcli/migration/phone_models.py +50 -0
  321. wxcli/migration/preflight/CLAUDE.md +52 -0
  322. wxcli/migration/preflight/__init__.py +89 -0
  323. wxcli/migration/preflight/checks.py +911 -0
  324. wxcli/migration/preflight/runner.py +293 -0
  325. wxcli/migration/rate_limiter.py +144 -0
  326. wxcli/migration/report/CLAUDE.md +245 -0
  327. wxcli/migration/report/__init__.py +7 -0
  328. wxcli/migration/report/appendix.py +2437 -0
  329. wxcli/migration/report/assembler.py +183 -0
  330. wxcli/migration/report/charts.py +263 -0
  331. wxcli/migration/report/executive.py +776 -0
  332. wxcli/migration/report/explainer.py +704 -0
  333. wxcli/migration/report/helpers.py +74 -0
  334. wxcli/migration/report/ingest.py +152 -0
  335. wxcli/migration/report/notice_templates.py +192 -0
  336. wxcli/migration/report/score.py +375 -0
  337. wxcli/migration/report/styles.py +1052 -0
  338. wxcli/migration/report/user_diff.py +1052 -0
  339. wxcli/migration/report/user_notice.py +425 -0
  340. wxcli/migration/state.py +139 -0
  341. wxcli/migration/store.py +860 -0
  342. wxcli/migration/transform/CLAUDE.md +107 -0
  343. wxcli/migration/transform/__init__.py +12 -0
  344. wxcli/migration/transform/analysis_pipeline.py +453 -0
  345. wxcli/migration/transform/analyzers/__init__.py +142 -0
  346. wxcli/migration/transform/analyzers/css_permission.py +189 -0
  347. wxcli/migration/transform/analyzers/css_routing.py +335 -0
  348. wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
  349. wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
  350. wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
  351. wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
  352. wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
  353. wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
  354. wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
  355. wxcli/migration/transform/analyzers/missing_data.py +300 -0
  356. wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
  357. wxcli/migration/transform/analyzers/shared_line.py +170 -0
  358. wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
  359. wxcli/migration/transform/analyzers/workspace_license.py +212 -0
  360. wxcli/migration/transform/cross_reference.py +1136 -0
  361. wxcli/migration/transform/cucm_pattern.py +246 -0
  362. wxcli/migration/transform/decisions.py +301 -0
  363. wxcli/migration/transform/e164.py +145 -0
  364. wxcli/migration/transform/engine.py +256 -0
  365. wxcli/migration/transform/mappers/CLAUDE.md +123 -0
  366. wxcli/migration/transform/mappers/__init__.py +76 -0
  367. wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
  368. wxcli/migration/transform/mappers/base.py +190 -0
  369. wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
  370. wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
  371. wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
  372. wxcli/migration/transform/mappers/css_mapper.py +868 -0
  373. wxcli/migration/transform/mappers/dect_mapper.py +367 -0
  374. wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
  375. wxcli/migration/transform/mappers/device_mapper.py +227 -0
  376. wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
  377. wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
  378. wxcli/migration/transform/mappers/e911_mapper.py +129 -0
  379. wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
  380. wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
  381. wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
  382. wxcli/migration/transform/mappers/line_mapper.py +310 -0
  383. wxcli/migration/transform/mappers/location_mapper.py +235 -0
  384. wxcli/migration/transform/mappers/moh_mapper.py +114 -0
  385. wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
  386. wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
  387. wxcli/migration/transform/mappers/routing_mapper.py +821 -0
  388. wxcli/migration/transform/mappers/snr_mapper.py +201 -0
  389. wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
  390. wxcli/migration/transform/mappers/user_mapper.py +264 -0
  391. wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
  392. wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
  393. wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
  394. wxcli/migration/transform/normalizers.py +2008 -0
  395. wxcli/migration/transform/pattern_converter.py +106 -0
  396. wxcli/migration/transform/pipeline.py +271 -0
  397. wxcli/migration/transform/rules.py +255 -0
  398. wxcli/org_health/CLAUDE.md +63 -0
  399. wxcli/org_health/__init__.py +7 -0
  400. wxcli/org_health/analyze.py +93 -0
  401. wxcli/org_health/checks.py +458 -0
  402. wxcli/org_health/collector.py +62 -0
  403. wxcli/org_health/models.py +78 -0
  404. wxcli/org_health/report.py +300 -0
  405. wxcli/output.py +101 -0
  406. wxcli-1.2.0.dist-info/METADATA +505 -0
  407. wxcli-1.2.0.dist-info/RECORD +413 -0
  408. wxcli-1.2.0.dist-info/WHEEL +5 -0
  409. wxcli-1.2.0.dist-info/entry_points.txt +2 -0
  410. wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
  411. wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
  412. wxcli-1.2.0.dist-info/scm_version.json +8 -0
  413. wxcli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1939 @@
1
+ """Execution planner — expand canonical objects into migration operations.
2
+
3
+ Each canonical object type has a deterministic expansion pattern that produces
4
+ one or more MigrationOp nodes for the dependency DAG. The planner only expands
5
+ objects at status 'analyzed' (all decisions resolved). Objects at
6
+ 'needs_decision' are skipped.
7
+
8
+ (from 05-dependency-graph.md — expand_to_operations lines 38-75)
9
+
10
+ Silent-skip visibility (2026-04-15):
11
+ Every short-circuit path that drops a canonical entity from plan_operations
12
+ must emit a ``logger.warning`` and record into ``PlannerSkipReport``. The
13
+ aggregate summary is logged at the end of ``expand_to_operations`` so
14
+ operators can see the full roll-up of skipped entities. This guards
15
+ against the ``DEVICE_FIRMWARE_CONVERTIBLE`` class of bug where a stale
16
+ decision silently dropped 611 convertible phones from the plan.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextvars
22
+ import logging
23
+ import os
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ from wxcli.migration.execute import (
28
+ API_CALL_ESTIMATES,
29
+ BULK_DEVICE_THRESHOLD_DEFAULT,
30
+ MigrationOp,
31
+ ORG_WIDE_TYPES,
32
+ TIER_ASSIGNMENTS,
33
+ )
34
+ from wxcli.migration.store import MigrationStore
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Silent-skip visibility (2026-04-15)
41
+ # ---------------------------------------------------------------------------
42
+
43
+ @dataclass
44
+ class PlannerSkipEntry:
45
+ """One skip event — identifies the entity, reason, and consequence."""
46
+ canonical_id: str
47
+ entity_type: str
48
+ reason: str # short machine-readable reason code
49
+ decision_type: str | None = None # DecisionType if skip was decision-gated
50
+ decision_state: str | None = None # "stale" | "pending" | "skip" | "virtual_line" | None
51
+ consequence: str = "" # human-readable consequence (one sentence)
52
+
53
+
54
+ @dataclass
55
+ class PlannerSkipReport:
56
+ """Aggregate report of all entities skipped by the planner.
57
+
58
+ Populated by ``expand_to_operations`` and every per-expander short-circuit.
59
+ Callers inspect ``entries`` for the full list and ``counts`` for a
60
+ by-reason roll-up. ``has_unresolved_skips`` flips True when any entry has
61
+ decision_state in {stale, pending} — the ``--fail-on-unresolved`` gate
62
+ keys off this flag.
63
+
64
+ ``needs_decision_counts`` is populated by ``expand_to_operations`` at the
65
+ end of a run. Entities with ``status='needs_decision'`` never reach
66
+ expansion (they have pending operator input) so they never appear in
67
+ ``entries``; the summary is needed separately so operators can see what
68
+ the plan is holding back. Keyed by ``DecisionType``, value is the count of
69
+ distinct entities held back by at least one pending decision of that type.
70
+ """
71
+ entries: list[PlannerSkipEntry] = field(default_factory=list)
72
+ needs_decision_counts: dict[str, int] = field(default_factory=dict)
73
+
74
+ def record(
75
+ self,
76
+ canonical_id: str,
77
+ entity_type: str,
78
+ reason: str,
79
+ consequence: str,
80
+ decision_type: str | None = None,
81
+ decision_state: str | None = None,
82
+ ) -> None:
83
+ self.entries.append(PlannerSkipEntry(
84
+ canonical_id=canonical_id,
85
+ entity_type=entity_type,
86
+ reason=reason,
87
+ decision_type=decision_type,
88
+ decision_state=decision_state,
89
+ consequence=consequence,
90
+ ))
91
+
92
+ @property
93
+ def counts(self) -> dict[str, int]:
94
+ """Group count by ``reason`` (or decision_type when decision-gated)."""
95
+ out: dict[str, int] = {}
96
+ for e in self.entries:
97
+ key = e.decision_type or e.reason
98
+ out[key] = out.get(key, 0) + 1
99
+ return out
100
+
101
+ @property
102
+ def has_unresolved_skips(self) -> bool:
103
+ """True if any entry was caused by a stale or pending decision."""
104
+ return any(
105
+ e.decision_state in ("stale", "pending")
106
+ for e in self.entries
107
+ )
108
+
109
+ def unresolved_entries(self) -> list[PlannerSkipEntry]:
110
+ """Entries whose skip was caused by an unresolved (stale/pending) decision."""
111
+ return [e for e in self.entries if e.decision_state in ("stale", "pending")]
112
+
113
+
114
+ def _warn_skip(
115
+ report: PlannerSkipReport,
116
+ canonical_id: str,
117
+ entity_type: str,
118
+ reason: str,
119
+ consequence: str,
120
+ decision_type: str | None = None,
121
+ decision_state: str | None = None,
122
+ ) -> None:
123
+ """Emit a WARN and record into the report.
124
+
125
+ Keeping WARN + record in one helper ensures every silent-skip site has
126
+ identical observability (log + report entry).
127
+ """
128
+ tag = decision_type or reason
129
+ logger.warning(
130
+ "Planner skip: %s %s (reason=%s%s) — %s",
131
+ entity_type,
132
+ canonical_id,
133
+ tag,
134
+ f", state={decision_state}" if decision_state else "",
135
+ consequence,
136
+ )
137
+ report.record(
138
+ canonical_id=canonical_id,
139
+ entity_type=entity_type,
140
+ reason=reason,
141
+ consequence=consequence,
142
+ decision_type=decision_type,
143
+ decision_state=decision_state,
144
+ )
145
+
146
+
147
+ class PlannerUnresolvedError(RuntimeError):
148
+ """Raised when fail-on-unresolved is enabled and unresolved skips were recorded."""
149
+
150
+
151
+ # Per-task current report. Set by ``expand_to_operations`` while it runs
152
+ # and cleared on exit. Expanders call ``_current_report()`` to access it;
153
+ # callers should NOT set this directly. This keeps the expander signatures
154
+ # small while still capturing every silent-skip site loudly.
155
+ #
156
+ # Backed by ``contextvars.ContextVar`` so concurrent planner runs (threads or
157
+ # asyncio tasks) see their own report, not a neighbor's. ``_set_current_report``
158
+ # returns the ``Token`` from ``set()`` so the caller can reset the variable to
159
+ # its prior value on exit — critical for correctness under nested / concurrent
160
+ # calls (reassigning to ``None`` would clobber an outer scope's report).
161
+ _CURRENT_REPORT: contextvars.ContextVar[PlannerSkipReport | None] = (
162
+ contextvars.ContextVar("planner_current_report", default=None)
163
+ )
164
+
165
+
166
+ def _current_report() -> PlannerSkipReport | None:
167
+ """Return the active planner skip report (or None if the planner is not running)."""
168
+ return _CURRENT_REPORT.get()
169
+
170
+
171
+ def _set_current_report(
172
+ report: PlannerSkipReport | None,
173
+ ) -> contextvars.Token[PlannerSkipReport | None]:
174
+ """Set the active planner skip report for the current context.
175
+
176
+ Returns a ``contextvars.Token`` that the caller should pass to
177
+ ``_CURRENT_REPORT.reset(token)`` once expansion is complete. Using the
178
+ token-based reset correctly restores the prior value under nested
179
+ expand_to_operations() calls, instead of unconditionally nuking the
180
+ ContextVar to ``None``.
181
+ """
182
+ return _CURRENT_REPORT.set(report)
183
+
184
+ # Decision types where chosen_option="skip" means "don't expand this object".
185
+ # (from phase-07-planning.md lines 26-27)
186
+ #
187
+ # Note: DEVICE_FIRMWARE_CONVERTIBLE is NOT in this set — as of 2026-04-15
188
+ # convertibility is a model classification, not a decision, and convertible
189
+ # devices always produce a create_activation_code op.
190
+ _SKIP_DECISION_TYPES = {
191
+ "DEVICE_INCOMPATIBLE",
192
+ "EXTENSION_CONFLICT",
193
+ "LOCATION_AMBIGUOUS",
194
+ "MISSING_DATA",
195
+ "WORKSPACE_LICENSE_TIER",
196
+ "DUPLICATE_USER",
197
+ "VOICEMAIL_INCOMPATIBLE",
198
+ }
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Decision-aware helpers
203
+ # ---------------------------------------------------------------------------
204
+
205
+ def _build_decisions_index(store: MigrationStore) -> dict[str, list[dict[str, Any]]]:
206
+ """Build a canonical_id → decisions index from all non-stale decisions.
207
+
208
+ Called once per expand_to_operations() run to avoid O(objects * decisions)
209
+ repeated queries.
210
+ """
211
+ all_decisions = store.get_all_decisions()
212
+ index: dict[str, list[dict[str, Any]]] = {}
213
+ for d in all_decisions:
214
+ if d.get("chosen_option") == "__stale__":
215
+ continue
216
+ context = d.get("context", {})
217
+ for cid in context.get("_affected_objects", []):
218
+ index.setdefault(cid, []).append(d)
219
+ return index
220
+
221
+
222
+ def _build_stale_decisions_index(store: MigrationStore) -> dict[str, list[dict[str, Any]]]:
223
+ """Build a canonical_id → stale decisions index.
224
+
225
+ Stale decisions (``chosen_option='__stale__'``) are normally invisible to
226
+ the planner. If an analyzed object has a stale decision attached, that
227
+ often means an upstream analyzer changed output between runs but the
228
+ object still carries a decision state the planner cannot interpret. The
229
+ ``DEVICE_FIRMWARE_CONVERTIBLE`` bug (2026-04-15) lived here — 611 devices
230
+ had stale decisions, the planner dropped them without logging.
231
+
232
+ Callers use this index to emit WARN events when a stale decision is
233
+ present alongside an analyzed object so the skip is loud, not silent.
234
+ """
235
+ all_decisions = store.get_all_decisions()
236
+ index: dict[str, list[dict[str, Any]]] = {}
237
+ for d in all_decisions:
238
+ if d.get("chosen_option") != "__stale__":
239
+ continue
240
+ context = d.get("context", {})
241
+ for cid in context.get("_affected_objects", []):
242
+ index.setdefault(cid, []).append(d)
243
+ return index
244
+
245
+
246
+ def _build_pending_decisions_index(store: MigrationStore) -> dict[str, list[dict[str, Any]]]:
247
+ """Build a canonical_id → pending (unresolved) decisions index.
248
+
249
+ A pending decision has ``chosen_option is None``. The planner only
250
+ expands objects at ``status='analyzed'`` and pending decisions should
251
+ have pushed the object to ``status='needs_decision'``, but the planner
252
+ still checks so any drift (e.g. analyzer emitted non-blocking decision
253
+ but marked blocking, or status-update bug) is caught loudly.
254
+ """
255
+ all_decisions = store.get_all_decisions()
256
+ index: dict[str, list[dict[str, Any]]] = {}
257
+ for d in all_decisions:
258
+ if d.get("chosen_option") is not None:
259
+ continue
260
+ context = d.get("context", {})
261
+ for cid in context.get("_affected_objects", []):
262
+ index.setdefault(cid, []).append(d)
263
+ return index
264
+
265
+
266
+ def _count_needs_decision_by_type(store: MigrationStore) -> dict[str, int]:
267
+ """Count entities at status='needs_decision' grouped by DecisionType.
268
+
269
+ Entities at ``needs_decision`` never reach expansion — they're held back
270
+ waiting for operator input. The aggregate skip summary would otherwise
271
+ hide them because no ``PlannerSkipEntry`` is ever recorded.
272
+
273
+ Each entity is counted once per pending decision type that references it
274
+ in ``context._affected_objects``. An entity held back by decisions of two
275
+ different types increments both buckets.
276
+
277
+ Returns a ``{decision_type: count}`` dict; empty dict if no entities are
278
+ at ``needs_decision``.
279
+ """
280
+ needs_decision_objects = store.query_by_status("needs_decision")
281
+ if not needs_decision_objects:
282
+ return {}
283
+ held_back_ids = {o.canonical_id for o in needs_decision_objects}
284
+
285
+ counts: dict[str, int] = {}
286
+ # One entity held by two pending decisions of the same type still counts
287
+ # as one in that bucket.
288
+ seen: dict[str, set[str]] = {}
289
+ for d in store.get_all_decisions():
290
+ if d.get("chosen_option") is not None:
291
+ continue
292
+ dtype = d.get("type")
293
+ if not dtype:
294
+ continue
295
+ affected = d.get("context", {}).get("_affected_objects", [])
296
+ for cid in affected:
297
+ if cid not in held_back_ids:
298
+ continue
299
+ if cid in seen.setdefault(dtype, set()):
300
+ continue
301
+ seen[dtype].add(cid)
302
+ counts[dtype] = counts.get(dtype, 0) + 1
303
+ return counts
304
+
305
+
306
+ def _find_skip_decision(decisions: list[dict[str, Any]]) -> dict[str, Any] | None:
307
+ """Return the first decision in _SKIP_DECISION_TYPES resolved as 'skip'."""
308
+ for d in decisions:
309
+ if d.get("type") in _SKIP_DECISION_TYPES and d.get("chosen_option") == "skip":
310
+ return d
311
+ return None
312
+
313
+
314
+ def _any_skip_decision(decisions: list[dict[str, Any]]) -> bool:
315
+ """Check if any decision in _SKIP_DECISION_TYPES is resolved as 'skip'."""
316
+ for d in decisions:
317
+ if d.get("type") in _SKIP_DECISION_TYPES and d.get("chosen_option") == "skip":
318
+ return True
319
+ return False
320
+
321
+
322
+ def _decision_chosen(decisions: list[dict[str, Any]], decision_type: str) -> str | None:
323
+ """Get the chosen option for a specific decision type, if resolved."""
324
+ for d in decisions:
325
+ if d.get("type") == decision_type and d.get("chosen_option") is not None:
326
+ return d["chosen_option"]
327
+ return None
328
+
329
+
330
+ # ---------------------------------------------------------------------------
331
+ # Bulk optimization pass
332
+ # (from docs/superpowers/specs/2026-04-10-bulk-operations.md §3b)
333
+ # ---------------------------------------------------------------------------
334
+
335
+ def _optimize_for_bulk(
336
+ ops: list[MigrationOp],
337
+ store: MigrationStore,
338
+ threshold: int,
339
+ skip_rebuild_phones: bool = False,
340
+ ) -> list[MigrationOp]:
341
+ """Replace per-device operations with bulk submissions.
342
+
343
+ When the number of unique devices being created meets or exceeds
344
+ ``threshold``, replaces:
345
+
346
+ - ``device:configure_settings`` ops → ``bulk_device_settings:submit``
347
+ (one per location)
348
+ - ``device_layout:configure`` ops → ``bulk_line_key_template:submit``
349
+ (one per (template_id, location))
350
+ - ``softkey_config:configure`` ops → ``bulk_dynamic_settings:submit``
351
+ (one per location)
352
+ - (post-all) → ``bulk_rebuild_phones:submit``
353
+ (one per location, tier 8)
354
+
355
+ ``device:create`` ops are never replaced — there is no bulk device
356
+ create API.
357
+
358
+ Below the threshold, the input list is returned unchanged.
359
+ """
360
+ device_creates = [o for o in ops if o.resource_type == "device" and o.op_type == "create"]
361
+ if len(device_creates) < threshold:
362
+ return ops
363
+
364
+ logger.info(
365
+ "Bulk optimization: device count %d >= threshold %d — rewriting ops",
366
+ len(device_creates), threshold,
367
+ )
368
+
369
+ result: list[MigrationOp] = []
370
+ # Track which locations needed bulk ops (for tier 8 rebuild phones later).
371
+ device_settings_locations: set[str] = set()
372
+ # Track covered device canonical_ids per location for fallback context.
373
+ device_settings_covered: dict[str, list[str]] = {}
374
+
375
+ for op in ops:
376
+ if op.resource_type == "device" and op.op_type == "configure_settings":
377
+ loc = op.batch or "org-wide"
378
+ device_settings_locations.add(loc)
379
+ device_settings_covered.setdefault(loc, []).append(op.canonical_id)
380
+ continue # drop the per-device op
381
+ result.append(op)
382
+
383
+ # Emit one bulk_device_settings:submit per location, at tier 5,
384
+ # dependent on all device:create ops in that location.
385
+ for loc in sorted(device_settings_locations):
386
+ dep_node_ids = [
387
+ _node_id(o.canonical_id, "create")
388
+ for o in ops
389
+ if o.resource_type == "device"
390
+ and o.op_type == "create"
391
+ and (o.batch or "org-wide") == loc
392
+ ]
393
+ bulk_cid = f"bulk_device_settings:{loc}"
394
+ result.append(MigrationOp(
395
+ canonical_id=bulk_cid,
396
+ op_type="submit",
397
+ resource_type="bulk_device_settings",
398
+ tier=TIER_ASSIGNMENTS[("bulk_device_settings", "submit")],
399
+ batch=loc if loc != "org-wide" else None,
400
+ api_calls=API_CALL_ESTIMATES["bulk_device_settings:submit"],
401
+ description=f"Bulk apply device settings at {loc}",
402
+ depends_on=dep_node_ids,
403
+ payload={
404
+ "location_canonical_id": loc,
405
+ "customizations": {},
406
+ "covered_canonical_ids": device_settings_covered.get(loc, []),
407
+ "fallback_handler_key": ["device", "configure_settings"],
408
+ },
409
+ ))
410
+
411
+ # ---- Line key template aggregation ----
412
+ # Group device_layout:configure ops by (template_canonical_id, location_canonical_id).
413
+ lkt_groups: dict[tuple[str, str], list[str]] = {} # (template, loc) → device_cids
414
+ lkt_layout_cids: dict[tuple[str, str], list[str]] = {} # (template, loc) → layout canonical_ids
415
+ layout_ops_to_remove: set[str] = set()
416
+
417
+ for op in ops:
418
+ if op.resource_type != "device_layout" or op.op_type != "configure":
419
+ continue
420
+ layout_obj = store.get_object(op.canonical_id)
421
+ if not layout_obj:
422
+ continue
423
+ data = layout_obj.model_dump() if hasattr(layout_obj, "model_dump") else layout_obj
424
+ template_cid = data.get("template_canonical_id") or ""
425
+ device_cid = data.get("device_canonical_id") or ""
426
+ if not template_cid or not device_cid:
427
+ continue
428
+ device_obj = store.get_object(device_cid)
429
+ if not device_obj:
430
+ continue
431
+ dev_data = device_obj.model_dump() if hasattr(device_obj, "model_dump") else device_obj
432
+ loc_cid = dev_data.get("location_canonical_id") or "org-wide"
433
+
434
+ lkt_groups.setdefault((template_cid, loc_cid), []).append(device_cid)
435
+ lkt_layout_cids.setdefault((template_cid, loc_cid), []).append(op.canonical_id)
436
+ layout_ops_to_remove.add(op.canonical_id)
437
+
438
+ # Filter out the replaced layout ops from result.
439
+ result = [
440
+ o for o in result
441
+ if not (o.resource_type == "device_layout"
442
+ and o.op_type == "configure"
443
+ and o.canonical_id in layout_ops_to_remove)
444
+ ]
445
+
446
+ # Emit bulk_line_key_template submissions.
447
+ for (template_cid, loc_cid), device_cids in sorted(lkt_groups.items()):
448
+ bulk_cid = f"bulk_line_key_template:{template_cid.split(':', 1)[-1]}:{loc_cid}"
449
+ dep_node_ids = [_node_id(template_cid, "create")]
450
+ dep_node_ids.extend(_node_id(dc, "create") for dc in device_cids)
451
+ result.append(MigrationOp(
452
+ canonical_id=bulk_cid,
453
+ op_type="submit",
454
+ resource_type="bulk_line_key_template",
455
+ tier=TIER_ASSIGNMENTS[("bulk_line_key_template", "submit")],
456
+ batch=loc_cid if loc_cid != "org-wide" else None,
457
+ api_calls=API_CALL_ESTIMATES["bulk_line_key_template:submit"],
458
+ description=f"Bulk apply line key template {template_cid} at {loc_cid}",
459
+ depends_on=dep_node_ids,
460
+ payload={
461
+ "template_canonical_id": template_cid,
462
+ "location_canonical_ids": [loc_cid],
463
+ "covered_canonical_ids": lkt_layout_cids.get((template_cid, loc_cid), []),
464
+ "fallback_handler_key": ["device_layout", "configure"],
465
+ },
466
+ ))
467
+
468
+ # ---- Dynamic device settings (PSK) aggregation ----
469
+ psk_groups: dict[str, list[str]] = {} # location_cid → device_cids
470
+ psk_softkey_cids: dict[str, list[str]] = {} # location_cid → softkey canonical_ids
471
+ softkey_ops_to_remove: set[str] = set()
472
+
473
+ for op in ops:
474
+ if op.resource_type != "softkey_config" or op.op_type != "configure":
475
+ continue
476
+ sk_obj = store.get_object(op.canonical_id)
477
+ if not sk_obj:
478
+ continue
479
+ sk_data = sk_obj.model_dump() if hasattr(sk_obj, "model_dump") else sk_obj
480
+ if not sk_data.get("is_psk_target"):
481
+ continue
482
+ device_cid = sk_data.get("device_canonical_id") or ""
483
+ if not device_cid:
484
+ continue
485
+ device_obj = store.get_object(device_cid)
486
+ if not device_obj:
487
+ continue
488
+ dev_data = device_obj.model_dump() if hasattr(device_obj, "model_dump") else device_obj
489
+ loc_cid = dev_data.get("location_canonical_id") or "org-wide"
490
+ psk_groups.setdefault(loc_cid, []).append(device_cid)
491
+ psk_softkey_cids.setdefault(loc_cid, []).append(op.canonical_id)
492
+ softkey_ops_to_remove.add(op.canonical_id)
493
+
494
+ result = [
495
+ o for o in result
496
+ if not (o.resource_type == "softkey_config"
497
+ and o.op_type == "configure"
498
+ and o.canonical_id in softkey_ops_to_remove)
499
+ ]
500
+
501
+ for loc_cid, device_cids in sorted(psk_groups.items()):
502
+ bulk_cid = f"bulk_dynamic_settings:{loc_cid}"
503
+ dep_node_ids = [_node_id(dc, "create") for dc in device_cids]
504
+ result.append(MigrationOp(
505
+ canonical_id=bulk_cid,
506
+ op_type="submit",
507
+ resource_type="bulk_dynamic_settings",
508
+ tier=TIER_ASSIGNMENTS[("bulk_dynamic_settings", "submit")],
509
+ batch=loc_cid if loc_cid != "org-wide" else None,
510
+ api_calls=API_CALL_ESTIMATES["bulk_dynamic_settings:submit"],
511
+ description=f"Bulk apply PSK/dynamic settings at {loc_cid}",
512
+ depends_on=dep_node_ids,
513
+ payload={
514
+ "location_canonical_id": loc_cid,
515
+ "tags": [],
516
+ "covered_canonical_ids": psk_softkey_cids.get(loc_cid, []),
517
+ "fallback_handler_key": ["softkey_config", "configure"],
518
+ },
519
+ ))
520
+
521
+ # ---- Rebuild phones (tier 8) ----
522
+ # One rebuild per location touched by any bulk op. Depends on every
523
+ # bulk op in that location so it runs strictly after them.
524
+ if skip_rebuild_phones:
525
+ logger.info("skip_rebuild_phones=True — omitting bulk_rebuild_phones ops")
526
+ return result
527
+
528
+ rebuild_locations: dict[str, list[str]] = {}
529
+
530
+ for op in result:
531
+ if op.resource_type not in {"bulk_device_settings",
532
+ "bulk_line_key_template",
533
+ "bulk_dynamic_settings"}:
534
+ continue
535
+ loc = op.batch or "org-wide"
536
+ rebuild_locations.setdefault(loc, []).append(
537
+ _node_id(op.canonical_id, op.op_type)
538
+ )
539
+
540
+ for loc, bulk_node_ids in sorted(rebuild_locations.items()):
541
+ bulk_cid = f"bulk_rebuild_phones:{loc}"
542
+ result.append(MigrationOp(
543
+ canonical_id=bulk_cid,
544
+ op_type="submit",
545
+ resource_type="bulk_rebuild_phones",
546
+ tier=TIER_ASSIGNMENTS[("bulk_rebuild_phones", "submit")],
547
+ batch=loc if loc != "org-wide" else None,
548
+ api_calls=API_CALL_ESTIMATES["bulk_rebuild_phones:submit"],
549
+ description=f"Bulk rebuild phones at {loc}",
550
+ depends_on=bulk_node_ids,
551
+ payload={
552
+ "location_canonical_id": loc,
553
+ },
554
+ ))
555
+
556
+ return result
557
+
558
+
559
+ # ---------------------------------------------------------------------------
560
+ # Operation builder helpers
561
+ # ---------------------------------------------------------------------------
562
+
563
+ def _op(
564
+ canonical_id: str,
565
+ op_type: str,
566
+ resource_type: str,
567
+ description: str,
568
+ depends_on: list[str] | None = None,
569
+ batch: str | None = None,
570
+ ) -> MigrationOp:
571
+ """Build a MigrationOp with tier and api_calls looked up from constants."""
572
+ tier = TIER_ASSIGNMENTS.get((resource_type, op_type), 0)
573
+ api_key = f"{resource_type}:{op_type}"
574
+ api_calls = API_CALL_ESTIMATES.get(api_key, 1)
575
+ if batch is None and resource_type in ORG_WIDE_TYPES:
576
+ batch = "org-wide"
577
+ return MigrationOp(
578
+ canonical_id=canonical_id,
579
+ op_type=op_type,
580
+ resource_type=resource_type,
581
+ tier=tier,
582
+ batch=batch,
583
+ api_calls=api_calls,
584
+ description=description,
585
+ depends_on=depends_on or [],
586
+ )
587
+
588
+
589
+ def _node_id(canonical_id: str, op_type: str) -> str:
590
+ """Consistent node ID format: 'canonical_id:op_type'.
591
+
592
+ To parse back: use rsplit(":", 1) — canonical_id may contain colons.
593
+ """
594
+ return f"{canonical_id}:{op_type}"
595
+
596
+
597
+ # ---------------------------------------------------------------------------
598
+ # Per-type expansion patterns
599
+ # (from 05-dependency-graph.md lines 41-74 — user and location examples)
600
+ # Other types follow the same deterministic pattern.
601
+ # ---------------------------------------------------------------------------
602
+
603
+ def _expand_location(obj: dict[str, Any]) -> list[MigrationOp]:
604
+ """Location → 2 ops: create + enable_calling (tier 0).
605
+
606
+ Fix 13: Creating a location does NOT enable Webex Calling on it.
607
+ A separate POST /v1/telephony/config/locations is required.
608
+ (from 05-dependency-graph.md lines 68-70, updated by Phase 12a Fix 13)
609
+ """
610
+ cid = obj["canonical_id"]
611
+ name = obj.get("name", cid)
612
+ return [
613
+ _op(cid, "create", "location", f"Create location {name}"),
614
+ _op(cid, "enable_calling", "location", f"Enable Webex Calling on {name}",
615
+ depends_on=[_node_id(cid, "create")]),
616
+ ]
617
+
618
+
619
+ def _expand_trunk(obj: dict[str, Any]) -> list[MigrationOp]:
620
+ """Trunk → 1 op: create (tier 1)."""
621
+ cid = obj["canonical_id"]
622
+ name = obj.get("name", cid)
623
+ return [_op(cid, "create", "trunk", f"Create trunk {name}")]
624
+
625
+
626
+ def _expand_route_group(obj: dict[str, Any]) -> list[MigrationOp]:
627
+ """Route group → 1 op: create (tier 1). Depends on its trunks."""
628
+ cid = obj["canonical_id"]
629
+ name = obj.get("name", cid)
630
+ deps = []
631
+ for gw in obj.get("local_gateways", []):
632
+ trunk_cid = gw.get("trunk_canonical_id")
633
+ if trunk_cid:
634
+ deps.append(_node_id(trunk_cid, "create"))
635
+ return [_op(cid, "create", "route_group", f"Create route group {name}", depends_on=deps)]
636
+
637
+
638
+ def _expand_route_list(obj: dict[str, Any]) -> list[MigrationOp]:
639
+ """Route list → 1-2 ops: create (tier 1) + optional configure_numbers.
640
+ Depends on its route group.
641
+ """
642
+ cid = obj["canonical_id"]
643
+ name = obj.get("name", cid)
644
+ deps = []
645
+ rg_cid = obj.get("route_group_id")
646
+ if rg_cid:
647
+ deps.append(_node_id(rg_cid, "create"))
648
+ ops = [_op(cid, "create", "route_list", f"Create route list {name}", depends_on=deps)]
649
+ numbers = obj.get("numbers", [])
650
+ if numbers:
651
+ ops.append(_op(
652
+ cid, "configure_numbers", "route_list",
653
+ f"Assign {len(numbers)} numbers to route list {name}",
654
+ depends_on=[_node_id(cid, "create")],
655
+ ))
656
+ return ops
657
+
658
+
659
+ def _expand_operating_mode(obj: dict[str, Any]) -> list[MigrationOp]:
660
+ """Operating mode (schedule) → 1 op: create (tier 1)."""
661
+ cid = obj["canonical_id"]
662
+ name = obj.get("name", cid)
663
+ return [_op(cid, "create", "operating_mode", f"Create operating mode {name}")]
664
+
665
+
666
+ def _expand_schedule(obj: dict[str, Any]) -> list[MigrationOp]:
667
+ """Location schedule → 1 op: create (tier 1)."""
668
+ cid = obj["canonical_id"]
669
+ name = obj.get("name", cid)
670
+ loc_id = obj.get("location_id")
671
+ return [_op(cid, "create", "schedule", f"Create location schedule {name}",
672
+ batch=loc_id)]
673
+
674
+
675
+ def _expand_user(obj: dict[str, Any]) -> list[MigrationOp]:
676
+ """User → 1-3 ops: create (always), configure_settings (if custom), configure_voicemail (if custom).
677
+
678
+ Fix 12: extension and license are included at creation time — the Webex
679
+ People API requires extension when callingData=true, and wxcli users create
680
+ has --license (not available on update).
681
+ Only generates configure_settings/configure_voicemail ops if the user has
682
+ non-default settings data to apply.
683
+ (from 05-dependency-graph.md lines 46-66, updated by Phase 12a Fix 12)
684
+ """
685
+ cid = obj["canonical_id"]
686
+ email = (obj.get("emails") or ["unknown"])[0]
687
+ loc_id = obj.get("location_id")
688
+ batch = loc_id if loc_id else None
689
+
690
+ ops = [_op(cid, "create", "user", f"Create person {email}", batch=batch)]
691
+
692
+ # Only generate settings op if user has custom call settings
693
+ settings = obj.get("call_settings") or obj.get("forwarding") or obj.get("call_forwarding")
694
+ if settings:
695
+ ops.append(_op(cid, "configure_settings", "user",
696
+ f"Configure call settings for {email}",
697
+ depends_on=[_node_id(cid, "create")], batch=batch))
698
+
699
+ # Only generate voicemail op if user has custom voicemail config
700
+ vm = obj.get("voicemail") or obj.get("voicemail_settings") or obj.get("voicemail_profile_id")
701
+ if vm:
702
+ ops.append(_op(cid, "configure_voicemail", "user",
703
+ f"Configure voicemail for {email}",
704
+ depends_on=[_node_id(cid, "create")], batch=batch))
705
+
706
+ return ops
707
+
708
+
709
+ def _expand_workspace(obj: dict[str, Any]) -> list[MigrationOp]:
710
+ """Workspace → 3 ops: create, assign_number, configure_settings."""
711
+ cid = obj["canonical_id"]
712
+ name = obj.get("display_name", cid)
713
+ loc_id = obj.get("location_id")
714
+ batch = loc_id if loc_id else None
715
+
716
+ return [
717
+ _op(cid, "create", "workspace", f"Create workspace {name}", batch=batch),
718
+ _op(cid, "assign_number", "workspace",
719
+ f"Assign number to workspace {name}",
720
+ depends_on=[_node_id(cid, "create")], batch=batch),
721
+ _op(cid, "configure_settings", "workspace",
722
+ f"Configure settings for workspace {name}",
723
+ depends_on=[_node_id(cid, "assign_number")], batch=batch),
724
+ ]
725
+
726
+
727
+ _NON_WEBEX_DEVICE_MODELS = {
728
+ "Cisco Unified Client Services Framework",
729
+ "Analog Phone",
730
+ "Cisco ATA 191",
731
+ }
732
+
733
+
734
+ def _expand_device(
735
+ obj: dict[str, Any],
736
+ decisions: list[dict[str, Any]],
737
+ config: dict | None = None,
738
+ ) -> list[MigrationOp]:
739
+ """Device → 1 op (CONVERTIBLE activation-code path) or 2 ops (MAC-based path).
740
+
741
+ - NATIVE_MPP / default: create (MAC-based POST /devices) + configure_settings
742
+ - CONVERTIBLE, default: single create_activation_code op, no configure_settings
743
+ (the phone auto-configures after registering). Emitted unconditionally —
744
+ convertibility is a model classification, not an operator choice.
745
+ - CONVERTIBLE, when config["convertible_provisioning"] == "mac" AND mac present:
746
+ same 2-op MAC path as NATIVE_MPP. Use this when CUCM→MPP firmware
747
+ conversion will auto-register the device against Webex by MAC so that
748
+ no end-user activation step is needed.
749
+ - webex_app / infrastructure / dect / non-Webex models: no ops
750
+ - MAC-based path (NATIVE_MPP/default, or CONVERTIBLE+mac) without a mac: no ops
751
+ (logged skip — POST /devices requires a MAC, so there's nothing to create)
752
+
753
+ Skip decisions handled generically in expand_to_operations.
754
+ (from 05-dependency-graph.md — device:create at tier 3, api_calls=2)
755
+ """
756
+ cid = obj["canonical_id"]
757
+ # Skip devices with non-Webex models (CSF soft phones, analog, ATA)
758
+ tier = obj.get("compatibility_tier")
759
+ model = obj.get("model")
760
+ if tier in ("webex_app", "infrastructure", "dect") or model in _NON_WEBEX_DEVICE_MODELS:
761
+ report = _current_report()
762
+ reason = (
763
+ f"compatibility_tier={tier}" if tier in ("webex_app", "infrastructure", "dect")
764
+ else f"non_webex_model={model}"
765
+ )
766
+ logger.info(
767
+ "Planner skip (expected): device %s (%s) — no plan ops produced",
768
+ cid, reason,
769
+ )
770
+ if report is not None:
771
+ report.record(
772
+ canonical_id=cid,
773
+ entity_type="device",
774
+ reason=reason,
775
+ consequence=(
776
+ "device will not be provisioned to Webex "
777
+ f"(model/tier classified as {reason})"
778
+ ),
779
+ )
780
+ return []
781
+ name = obj.get("display_name") or obj.get("mac") or cid
782
+ owner_cid = obj.get("owner_canonical_id")
783
+ loc_cid = obj.get("location_canonical_id")
784
+ batch = loc_cid if loc_cid else None
785
+
786
+ deps_create: list[str] = []
787
+ if owner_cid:
788
+ deps_create.append(_node_id(owner_cid, "create"))
789
+
790
+ if obj.get("compatibility_tier") == "convertible":
791
+ # Check whether the operator wants MAC-based provisioning for convertibles.
792
+ # Requires config["convertible_provisioning"] == "mac" AND a MAC on the device.
793
+ use_mac = (
794
+ (config or {}).get("convertible_provisioning") == "mac"
795
+ and bool(obj.get("mac"))
796
+ )
797
+ if not use_mac:
798
+ return [
799
+ _op(cid, "create_activation_code", "device",
800
+ f"Generate activation code for {name}",
801
+ depends_on=deps_create, batch=batch),
802
+ ]
803
+ # Fall through to the MAC-based 2-op path below.
804
+
805
+ if not obj.get("mac"):
806
+ report = _current_report()
807
+ if report is not None:
808
+ _warn_skip(
809
+ report,
810
+ canonical_id=cid,
811
+ entity_type="device",
812
+ reason="missing_mac",
813
+ consequence=(
814
+ "device will not be provisioned to Webex "
815
+ "(MAC-based create requires a MAC address)"
816
+ ),
817
+ )
818
+ return []
819
+
820
+ return [
821
+ _op(cid, "create", "device", f"Create device {name}",
822
+ depends_on=deps_create, batch=batch),
823
+ _op(cid, "configure_settings", "device", f"Configure device {name}",
824
+ depends_on=[_node_id(cid, "create")], batch=batch),
825
+ ]
826
+
827
+
828
+ def _expand_dial_plan(obj: dict[str, Any]) -> list[MigrationOp]:
829
+ """Dial plan → 1 op: create (tier 2, org-wide)."""
830
+ cid = obj["canonical_id"]
831
+ name = obj.get("name", cid)
832
+ deps = []
833
+ route_id = obj.get("route_id")
834
+ if route_id:
835
+ deps.append(_node_id(route_id, "create"))
836
+ return [_op(cid, "create", "dial_plan", f"Create dial plan {name}", depends_on=deps)]
837
+
838
+
839
+ def _expand_translation_pattern(obj: dict[str, Any]) -> list[MigrationOp]:
840
+ """Translation pattern → 1 op: create (tier 2, org-wide)."""
841
+ cid = obj["canonical_id"]
842
+ name = obj.get("name") or obj.get("matching_pattern") or cid
843
+ return [_op(cid, "create", "translation_pattern", f"Create translation pattern {name}")]
844
+
845
+
846
+ def _expand_calling_permission(obj: dict[str, Any]) -> list[MigrationOp]:
847
+ """Calling permission → 0-1 ops: assign op per assigned user (tier 5).
848
+
849
+ No standalone create op — Webex permissions are per-user, not standalone
850
+ resources. Only generates an assign op if there are users to apply to.
851
+ """
852
+ cid = obj["canonical_id"]
853
+ assigned_users = obj.get("assigned_users", [])
854
+ if not assigned_users:
855
+ report = _current_report()
856
+ if report is not None:
857
+ _warn_skip(
858
+ report,
859
+ canonical_id=cid,
860
+ entity_type="calling_permission",
861
+ reason="no_assigned_users",
862
+ consequence=(
863
+ "calling permission will not be assigned "
864
+ "(assigned_users list is empty — no user will inherit this permission)"
865
+ ),
866
+ )
867
+ return []
868
+
869
+ api_calls = len(assigned_users)
870
+ return [MigrationOp(
871
+ canonical_id=cid,
872
+ op_type="assign",
873
+ resource_type="calling_permission",
874
+ tier=TIER_ASSIGNMENTS[("calling_permission", "assign")],
875
+ batch="org-wide",
876
+ api_calls=api_calls,
877
+ description=f"Assign calling permissions to {api_calls} users",
878
+ depends_on=[_node_id(uid, "create") for uid in assigned_users],
879
+ )]
880
+
881
+
882
+ def _expand_hunt_group(obj: dict[str, Any]) -> list[MigrationOp]:
883
+ """Hunt group → 1-2 ops: create (tier 4) + optional configure_forwarding (tier 5)."""
884
+ cid = obj["canonical_id"]
885
+ name = obj.get("name", cid)
886
+ loc_id = _location_from_provenance(obj)
887
+ deps = [_node_id(uid, "create") for uid in obj.get("agents", [])]
888
+ ops = [_op(cid, "create", "hunt_group", f"Create hunt group {name}",
889
+ depends_on=deps, batch=loc_id)]
890
+
891
+ has_forwarding = any([
892
+ obj.get("forward_always_enabled"),
893
+ obj.get("forward_busy_enabled"),
894
+ obj.get("forward_no_answer_enabled"),
895
+ ])
896
+ if has_forwarding:
897
+ ops.append(_op(
898
+ cid, "configure_forwarding", "hunt_group",
899
+ f"Configure forwarding for hunt group {name}",
900
+ depends_on=[_node_id(cid, "create")], batch=loc_id,
901
+ ))
902
+ return ops
903
+
904
+
905
+ def _expand_call_queue(obj: dict[str, Any]) -> list[MigrationOp]:
906
+ """Call queue → 1-5 ops: create (tier 4) + optional configure_* (tier 5)."""
907
+ cid = obj["canonical_id"]
908
+ name = obj.get("name", cid)
909
+ loc_id = _location_from_provenance(obj)
910
+ deps = [_node_id(uid, "create") for uid in obj.get("agents", [])]
911
+ ops = [_op(cid, "create", "call_queue", f"Create call queue {name}",
912
+ depends_on=deps, batch=loc_id)]
913
+
914
+ create_dep = [_node_id(cid, "create")]
915
+
916
+ has_forwarding = any([
917
+ obj.get("forward_always_enabled"),
918
+ obj.get("queue_full_destination"),
919
+ obj.get("max_wait_time_destination"),
920
+ ])
921
+ if has_forwarding:
922
+ ops.append(_op(
923
+ cid, "configure_forwarding", "call_queue",
924
+ f"Configure forwarding for call queue {name}",
925
+ depends_on=create_dep, batch=loc_id,
926
+ ))
927
+
928
+ if obj.get("holiday_service_enabled"):
929
+ ops.append(_op(
930
+ cid, "configure_holiday_service", "call_queue",
931
+ f"Configure holiday service for call queue {name}",
932
+ depends_on=create_dep, batch=loc_id,
933
+ ))
934
+
935
+ if obj.get("night_service_enabled"):
936
+ ops.append(_op(
937
+ cid, "configure_night_service", "call_queue",
938
+ f"Configure night service for call queue {name}",
939
+ depends_on=create_dep, batch=loc_id,
940
+ ))
941
+
942
+ if obj.get("no_agent_destination"):
943
+ ops.append(_op(
944
+ cid, "configure_stranded_calls", "call_queue",
945
+ f"Configure stranded calls for call queue {name}",
946
+ depends_on=create_dep, batch=loc_id,
947
+ ))
948
+
949
+ return ops
950
+
951
+
952
+ def _expand_auto_attendant(obj: dict[str, Any]) -> list[MigrationOp]:
953
+ """Auto attendant → 1-2 ops: create (tier 4) + optional configure_forwarding (tier 5)."""
954
+ cid = obj["canonical_id"]
955
+ name = obj.get("name", cid)
956
+ loc_id = _location_from_provenance(obj)
957
+ ops = [_op(cid, "create", "auto_attendant", f"Create auto attendant {name}",
958
+ batch=loc_id)]
959
+
960
+ if obj.get("forward_always_enabled"):
961
+ ops.append(_op(
962
+ cid, "configure_forwarding", "auto_attendant",
963
+ f"Configure forwarding for auto attendant {name}",
964
+ depends_on=[_node_id(cid, "create")], batch=loc_id,
965
+ ))
966
+
967
+ return ops
968
+
969
+
970
+ def _expand_call_park(obj: dict[str, Any]) -> list[MigrationOp]:
971
+ """Call park → 1 op: create (tier 4)."""
972
+ cid = obj["canonical_id"]
973
+ name = obj.get("name") or obj.get("extension") or cid
974
+ loc_id = obj.get("location_id")
975
+ return [_op(cid, "create", "call_park", f"Create call park {name}",
976
+ batch=loc_id)]
977
+
978
+
979
+ def _expand_pickup_group(obj: dict[str, Any]) -> list[MigrationOp]:
980
+ """Pickup group → 1 op: create (tier 4)."""
981
+ cid = obj["canonical_id"]
982
+ name = obj.get("name", cid)
983
+ loc_id = _location_from_provenance(obj)
984
+ deps = [_node_id(uid, "create") for uid in obj.get("agents", [])]
985
+ return [_op(cid, "create", "pickup_group", f"Create pickup group {name}",
986
+ depends_on=deps, batch=loc_id)]
987
+
988
+
989
+ def _expand_paging_group(obj: dict[str, Any]) -> list[MigrationOp]:
990
+ """Paging group → 1 op: create (tier 4)."""
991
+ cid = obj["canonical_id"]
992
+ name = obj.get("name", cid)
993
+ loc_id = _location_from_provenance(obj)
994
+ deps = [_node_id(uid, "create") for uid in obj.get("targets", [])]
995
+ return [_op(cid, "create", "paging_group", f"Create paging group {name}",
996
+ depends_on=deps, batch=loc_id)]
997
+
998
+
999
+ def _expand_voicemail_group(obj: dict[str, Any]) -> list[MigrationOp]:
1000
+ """Voicemail group -> 1 op: create.
1001
+
1002
+ Depends on the location's calling having been enabled. The batch
1003
+ partitioner groups this with other features in the same location.
1004
+ (from docs/superpowers/specs/2026-04-10-voicemail-groups.md)
1005
+ """
1006
+ cid = obj["canonical_id"]
1007
+ name = obj.get("name", cid)
1008
+ loc_id = obj.get("location_id")
1009
+ deps: list[str] = []
1010
+ if loc_id:
1011
+ deps.append(_node_id(loc_id, "enable_calling"))
1012
+ return [_op(
1013
+ cid,
1014
+ "create",
1015
+ "voicemail_group",
1016
+ f"Create voicemail group {name}",
1017
+ depends_on=deps,
1018
+ batch=loc_id,
1019
+ )]
1020
+
1021
+
1022
+ def _expand_shared_line(
1023
+ obj: dict[str, Any],
1024
+ decisions: list[dict[str, Any]],
1025
+ ) -> list[MigrationOp]:
1026
+ """Shared line → 1 op: configure (tier 6).
1027
+ Decision SHARED_LINE_COMPLEX can change expansion:
1028
+ - 'virtual_line' → creates a virtual line instead (handled via virtual_line object)
1029
+ - 'skip' → no operations
1030
+ """
1031
+ choice = _decision_chosen(decisions, "SHARED_LINE_COMPLEX")
1032
+ cid = obj["canonical_id"]
1033
+ report = _current_report()
1034
+ if choice == "skip":
1035
+ if report is not None:
1036
+ _warn_skip(
1037
+ report,
1038
+ canonical_id=cid,
1039
+ entity_type="shared_line",
1040
+ reason="shared_line_complex_skip",
1041
+ decision_type="SHARED_LINE_COMPLEX",
1042
+ decision_state="skip",
1043
+ consequence=(
1044
+ "shared line appearances will not be configured "
1045
+ "(operator chose 'skip' for SHARED_LINE_COMPLEX)"
1046
+ ),
1047
+ )
1048
+ return []
1049
+ if choice == "virtual_line":
1050
+ # Virtual line creation handled by the CanonicalVirtualLine object
1051
+ # that was created during analysis. No shared_line ops needed.
1052
+ # This is NOT a silent skip — a CanonicalVirtualLine was emitted by
1053
+ # the analyzer, so ops still land in the plan via _expand_virtual_line.
1054
+ # We note it as informational only.
1055
+ logger.info(
1056
+ "Planner: shared_line %s → virtual_line substitution "
1057
+ "(ops produced via CanonicalVirtualLine)", cid,
1058
+ )
1059
+ return []
1060
+
1061
+ cid = obj["canonical_id"]
1062
+ owner_ids = obj.get("owner_canonical_ids", [])
1063
+ deps = [_node_id(uid, "create") for uid in owner_ids]
1064
+ # Batch assignment deferred — shared lines span users which may be in
1065
+ # different sites. The batch partitioner handles unassigned ops as org-wide.
1066
+ return [_op(cid, "configure", "shared_line",
1067
+ f"Configure shared line appearances for {cid}",
1068
+ depends_on=deps)]
1069
+
1070
+
1071
+ def _expand_virtual_line(obj: dict[str, Any]) -> list[MigrationOp]:
1072
+ """Virtual line → 2 ops: create (tier 6), configure (tier 6)."""
1073
+ cid = obj["canonical_id"]
1074
+ name = obj.get("display_name") or obj.get("extension") or cid
1075
+ loc_id = obj.get("location_id")
1076
+ return [
1077
+ _op(cid, "create", "virtual_line", f"Create virtual line {name}",
1078
+ batch=loc_id),
1079
+ _op(cid, "configure", "virtual_line", f"Configure virtual line {name}",
1080
+ depends_on=[_node_id(cid, "create")], batch=loc_id),
1081
+ ]
1082
+
1083
+
1084
+ def _expand_line_key_template(obj: dict[str, Any]) -> list[MigrationOp]:
1085
+ """Line key template → 1 op: create (tier 1, org-wide).
1086
+ Skip if phones_using == 0 (dead template — nothing will reference it).
1087
+ """
1088
+ cid = obj["canonical_id"]
1089
+ if obj.get("phones_using", 0) == 0:
1090
+ report = _current_report()
1091
+ if report is not None:
1092
+ _warn_skip(
1093
+ report,
1094
+ canonical_id=cid,
1095
+ entity_type="line_key_template",
1096
+ reason="dead_template_zero_phones",
1097
+ consequence=(
1098
+ "line key template will not be created "
1099
+ "(phones_using=0 — no device references this template)"
1100
+ ),
1101
+ )
1102
+ return []
1103
+ name = obj.get("name") or cid
1104
+ return [_op(cid, "create", "line_key_template", f"Create line key template {name}")]
1105
+
1106
+
1107
+ def _expand_call_forwarding(obj: dict[str, Any]) -> list[MigrationOp]:
1108
+ """Call forwarding → 0-1 ops: configure (tier 5).
1109
+ Skip if all forwarding types are disabled.
1110
+ batch=None — the batch partitioner assigns unassigned ops as org-wide.
1111
+ """
1112
+ cid = obj["canonical_id"]
1113
+ if not any([
1114
+ obj.get("always_enabled"),
1115
+ obj.get("busy_enabled"),
1116
+ obj.get("no_answer_enabled"),
1117
+ ]):
1118
+ # Expected no-op: forwarding object exists but all types disabled.
1119
+ # INFO only — no entity would be created for empty forwarding state.
1120
+ report = _current_report()
1121
+ logger.info(
1122
+ "Planner skip (expected): call_forwarding %s — all forwarding "
1123
+ "types disabled, no configure op needed", cid,
1124
+ )
1125
+ if report is not None:
1126
+ report.record(
1127
+ canonical_id=cid,
1128
+ entity_type="call_forwarding",
1129
+ reason="all_forwarding_disabled",
1130
+ consequence=(
1131
+ "no forwarding ops emitted (all always/busy/no_answer disabled)"
1132
+ ),
1133
+ )
1134
+ return []
1135
+ user_cid = obj.get("user_canonical_id")
1136
+ deps = [_node_id(user_cid, "create")] if user_cid else []
1137
+ return [_op(cid, "configure", "call_forwarding",
1138
+ f"Configure call forwarding for {cid}",
1139
+ depends_on=deps)]
1140
+
1141
+
1142
+ def _expand_ecbn_config(obj: dict[str, Any]) -> list[MigrationOp]:
1143
+ """ECBN config → 1 op: configure (tier 5).
1144
+
1145
+ Depends on the underlying entity (user/workspace/virtual_line) being created.
1146
+ (from 2026-04-10-e911-ecbn-execution.md §4.5)
1147
+ """
1148
+ cid = obj["canonical_id"]
1149
+ entity_cid = obj.get("entity_canonical_id", "")
1150
+ loc_cid = obj.get("location_canonical_id")
1151
+ batch = loc_cid if loc_cid else None
1152
+
1153
+ deps: list[str] = []
1154
+ if entity_cid:
1155
+ deps.append(_node_id(entity_cid, "create"))
1156
+
1157
+ return [_op(
1158
+ canonical_id=cid,
1159
+ op_type="configure",
1160
+ resource_type="ecbn_config",
1161
+ description=f"Configure ECBN for {entity_cid}",
1162
+ depends_on=deps,
1163
+ batch=batch,
1164
+ )]
1165
+
1166
+
1167
+ def _expand_single_number_reach(obj: dict[str, Any]) -> list[MigrationOp]:
1168
+ """Single Number Reach → 0-1 ops: configure (tier 5).
1169
+ Skip if no numbers.
1170
+ """
1171
+ cid = obj["canonical_id"]
1172
+ if not obj.get("numbers"):
1173
+ report = _current_report()
1174
+ if report is not None:
1175
+ _warn_skip(
1176
+ report,
1177
+ canonical_id=cid,
1178
+ entity_type="single_number_reach",
1179
+ reason="no_numbers",
1180
+ consequence=(
1181
+ "SNR will not be configured for user "
1182
+ "(numbers list is empty on the canonical object)"
1183
+ ),
1184
+ )
1185
+ return []
1186
+ user_cid = obj.get("user_canonical_id")
1187
+ deps = [_node_id(user_cid, "create")] if user_cid else []
1188
+ return [_op(cid, "configure", "single_number_reach",
1189
+ f"Configure SNR for {cid}",
1190
+ depends_on=deps)]
1191
+
1192
+
1193
+ def _expand_monitoring_list(obj: dict[str, Any]) -> list[MigrationOp]:
1194
+ """Monitoring list → 0-1 ops: configure (tier 6).
1195
+ Skip if no monitored_members.
1196
+ batch=None — the batch partitioner assigns unassigned ops as org-wide.
1197
+ """
1198
+ cid = obj["canonical_id"]
1199
+ if not obj.get("monitored_members"):
1200
+ report = _current_report()
1201
+ if report is not None:
1202
+ _warn_skip(
1203
+ report,
1204
+ canonical_id=cid,
1205
+ entity_type="monitoring_list",
1206
+ reason="no_monitored_members",
1207
+ consequence=(
1208
+ "BLF/monitoring list will not be configured "
1209
+ "(no resolved monitored_members on the canonical object)"
1210
+ ),
1211
+ )
1212
+ return []
1213
+ user_cid = obj.get("user_canonical_id")
1214
+ deps = [_node_id(user_cid, "create")] if user_cid else []
1215
+ for m in obj.get("monitored_members", []):
1216
+ target_cid = m.get("target_canonical_id")
1217
+ if target_cid:
1218
+ deps.append(_node_id(target_cid, "create"))
1219
+ return [_op(cid, "configure", "monitoring_list",
1220
+ f"Configure monitoring list for {cid}",
1221
+ depends_on=deps)]
1222
+
1223
+
1224
+ def _expand_device_layout(obj: dict[str, Any]) -> list[MigrationOp]:
1225
+ """Device layout → 0-1 ops: configure (tier 7).
1226
+ Skip if no resolved_line_keys and no template referenced.
1227
+ batch=None — the batch partitioner assigns unassigned ops as org-wide.
1228
+ """
1229
+ cid = obj["canonical_id"]
1230
+ resolved_keys = obj.get("resolved_line_keys", [])
1231
+ template_cid = obj.get("template_canonical_id")
1232
+ if not resolved_keys and not template_cid:
1233
+ report = _current_report()
1234
+ if report is not None:
1235
+ _warn_skip(
1236
+ report,
1237
+ canonical_id=cid,
1238
+ entity_type="device_layout",
1239
+ reason="no_layout_content",
1240
+ consequence=(
1241
+ "device layout will not be configured "
1242
+ "(no resolved_line_keys and no template_canonical_id)"
1243
+ ),
1244
+ )
1245
+ return []
1246
+ deps = []
1247
+ device_cid = obj.get("device_canonical_id")
1248
+ if device_cid:
1249
+ deps.append(_node_id(device_cid, "create"))
1250
+ if template_cid:
1251
+ deps.append(_node_id(template_cid, "create"))
1252
+ owner_cid = obj.get("owner_canonical_id")
1253
+ if owner_cid:
1254
+ deps.append(_node_id(owner_cid, "create"))
1255
+ for m in obj.get("line_members", []):
1256
+ member_cid = m.get("member_canonical_id")
1257
+ if member_cid:
1258
+ deps.append(_node_id(member_cid, "create"))
1259
+ return [_op(cid, "configure", "device_layout",
1260
+ f"Configure layout for {cid}",
1261
+ depends_on=deps)]
1262
+
1263
+
1264
+ def _expand_softkey_config(obj: dict[str, Any]) -> list[MigrationOp]:
1265
+ """Softkey config → 0-1 ops: configure (tier 7).
1266
+ Only per-device objects (is_psk_target=True) produce ops.
1267
+ Template-level objects (is_psk_target=False) are report-only.
1268
+ batch=None — the batch partitioner assigns unassigned ops as org-wide.
1269
+ """
1270
+ cid = obj["canonical_id"]
1271
+ report = _current_report()
1272
+ if not obj.get("is_psk_target"):
1273
+ # Template-level objects are deliberately report-only; INFO only.
1274
+ logger.info(
1275
+ "Planner skip (expected): softkey_config %s — template-level "
1276
+ "(is_psk_target=False, report-only)", cid,
1277
+ )
1278
+ if report is not None:
1279
+ report.record(
1280
+ canonical_id=cid,
1281
+ entity_type="softkey_config",
1282
+ reason="template_level_report_only",
1283
+ consequence=(
1284
+ "no PSK ops emitted (template-level softkey config is "
1285
+ "report-only, not per-device)"
1286
+ ),
1287
+ )
1288
+ return []
1289
+ device_cid = obj.get("device_canonical_id")
1290
+ if not device_cid:
1291
+ if report is not None:
1292
+ _warn_skip(
1293
+ report,
1294
+ canonical_id=cid,
1295
+ entity_type="softkey_config",
1296
+ reason="no_device_canonical_id",
1297
+ consequence=(
1298
+ "PSK softkeys will not be configured "
1299
+ "(is_psk_target=True but device_canonical_id missing)"
1300
+ ),
1301
+ )
1302
+ return []
1303
+ return [_op(cid, "configure", "softkey_config",
1304
+ f"Configure PSK softkeys for {cid}",
1305
+ depends_on=[_node_id(device_cid, "create")])]
1306
+
1307
+
1308
+ def _expand_device_settings_template(
1309
+ obj: dict[str, Any], decisions: list,
1310
+ ) -> list[MigrationOp]:
1311
+ """device_settings_template → location settings + per-device overrides."""
1312
+ cid = obj["canonical_id"]
1313
+ settings = obj.get("settings") or {}
1314
+ phones_using = obj.get("phones_using", 0)
1315
+ if not settings or phones_using == 0:
1316
+ report = _current_report()
1317
+ if report is not None:
1318
+ reason = "no_settings" if not settings else "zero_phones_using"
1319
+ _warn_skip(
1320
+ report,
1321
+ canonical_id=cid,
1322
+ entity_type="device_settings_template",
1323
+ reason=reason,
1324
+ consequence=(
1325
+ "device settings template will not be applied "
1326
+ f"({reason} — nothing to apply or no phones to apply it to)"
1327
+ ),
1328
+ )
1329
+ return []
1330
+
1331
+ family = obj.get("model_family", "unknown")
1332
+ loc_id = obj.get("location_canonical_id", "unknown")
1333
+ ops = [
1334
+ _op(cid, "apply_location_settings", "device_settings_template",
1335
+ f"Apply {family} device settings at {loc_id}"),
1336
+ ]
1337
+
1338
+ for override in obj.get("per_device_overrides", []):
1339
+ dev_cid = override.get("device_canonical_id", "")
1340
+ ops.append(_op(
1341
+ cid, "apply_device_override", "device_settings_template",
1342
+ f"Override device settings for {dev_cid}",
1343
+ depends_on=[_node_id(cid, "apply_location_settings")],
1344
+ ))
1345
+
1346
+ return ops
1347
+
1348
+
1349
+ def _expand_device_profile(obj: dict[str, Any]) -> list[MigrationOp]:
1350
+ """Device profile → 1-2 ops: enable_hoteling_guest (tier 5), enable_hoteling_host (tier 5).
1351
+ Guest op requires user_canonical_id. Host op requires host_device_canonical_ids.
1352
+ """
1353
+ cid = obj["canonical_id"]
1354
+ name = obj.get("profile_name") or cid
1355
+ user_cid = obj.get("user_canonical_id")
1356
+ ops: list[MigrationOp] = []
1357
+
1358
+ if obj.get("hoteling_guest_enabled") and user_cid:
1359
+ ops.append(_op(cid, "enable_hoteling_guest", "device_profile",
1360
+ f"Enable hoteling guest for {name}",
1361
+ depends_on=[_node_id(user_cid, "create")]))
1362
+
1363
+ host_cids = obj.get("host_device_canonical_ids") or []
1364
+ if host_cids and user_cid:
1365
+ deps = [_node_id(user_cid, "create")]
1366
+ for hcid in host_cids:
1367
+ deps.append(_node_id(hcid, "create"))
1368
+ ops.append(_op(cid, "enable_hoteling_host", "device_profile",
1369
+ f"Configure hoteling host for {name}",
1370
+ depends_on=deps))
1371
+
1372
+ return ops
1373
+
1374
+
1375
+ def _expand_hoteling_location(obj: dict[str, Any]) -> list[MigrationOp]:
1376
+ """Hoteling location → 1 op: enable_hotdesking (tier 0).
1377
+ Enables voice portal hot desk sign-in at a location with EM phones.
1378
+ """
1379
+ cid = obj["canonical_id"]
1380
+ state = obj.get("pre_migration_state") or {}
1381
+ loc_cid = state.get("location_canonical_id", "")
1382
+ deps = [_node_id(loc_cid, "enable_calling")] if loc_cid else []
1383
+ return [_op(cid, "enable_hotdesking", "hoteling_location",
1384
+ f"Enable hot desking at {loc_cid}",
1385
+ depends_on=deps)]
1386
+
1387
+
1388
+ def _expand_dect_network(obj: dict[str, Any]) -> list[MigrationOp]:
1389
+ """DECT network → 3 ops: create_dect_network, create_base_stations, assign_handsets.
1390
+
1391
+ Dependency chain:
1392
+ create_dect_network → depends on location:create
1393
+ create_base_stations → depends on create_dect_network
1394
+ assign_handsets → depends on create_base_stations + each owner user:create/workspace:create
1395
+
1396
+ Handset assignments are stored as-is in the op data; the handler is
1397
+ responsible for batching API calls if the DECT API imposes per-request limits.
1398
+ (from spec §5d — _expand_dect_network requirements)
1399
+ """
1400
+ cid = obj["canonical_id"]
1401
+ name = obj.get("display_name") or obj.get("network_name") or cid
1402
+ loc_cid = obj.get("location_canonical_id")
1403
+ batch = loc_cid if loc_cid else None
1404
+
1405
+ # Op 1: create the DECT network
1406
+ create_deps: list[str] = []
1407
+ if loc_cid:
1408
+ create_deps.append(_node_id(loc_cid, "create"))
1409
+
1410
+ op_create = _op(
1411
+ cid, "create", "dect_network",
1412
+ f"Create DECT network {name}",
1413
+ depends_on=create_deps,
1414
+ batch=batch,
1415
+ )
1416
+
1417
+ # Op 2: register base stations (depends on network existing)
1418
+ op_base_stations = _op(
1419
+ cid, "create_base_stations", "dect_network",
1420
+ f"Register base stations for DECT network {name}",
1421
+ depends_on=[_node_id(cid, "create")],
1422
+ batch=batch,
1423
+ )
1424
+
1425
+ # Op 3: assign handsets (depends on base stations + all owner entities)
1426
+ handset_deps: list[str] = [_node_id(cid, "create_base_stations")]
1427
+ handset_assignments = obj.get("handset_assignments") or []
1428
+ for handset in handset_assignments:
1429
+ owner_cid = handset.get("user_canonical_id")
1430
+ if owner_cid:
1431
+ if owner_cid.startswith("user:"):
1432
+ handset_deps.append(_node_id(owner_cid, "create"))
1433
+ elif owner_cid.startswith("workspace:"):
1434
+ handset_deps.append(_node_id(owner_cid, "create"))
1435
+ # Deduplicate while preserving order
1436
+ seen: set[str] = set()
1437
+ unique_handset_deps: list[str] = []
1438
+ for dep in handset_deps:
1439
+ if dep not in seen:
1440
+ seen.add(dep)
1441
+ unique_handset_deps.append(dep)
1442
+
1443
+ op_assign_handsets = _op(
1444
+ cid, "assign_handsets", "dect_network",
1445
+ f"Assign handsets to DECT network {name}",
1446
+ depends_on=unique_handset_deps,
1447
+ batch=batch,
1448
+ )
1449
+
1450
+ return [op_create, op_base_stations, op_assign_handsets]
1451
+
1452
+
1453
+ def _expand_music_on_hold(obj: dict[str, Any]) -> list[MigrationOp]:
1454
+ """Music on hold → 1 op: configure (tier 5).
1455
+
1456
+ Phase A: the handler is a no-op placeholder. The op exists so operators
1457
+ see music_on_hold tracked in the deployment plan; the MOHMapper's
1458
+ AUDIO_ASSET_MANUAL decisions still gate custom audio. Real per-location
1459
+ PUT /telephony/config/locations/{id}/musicOnHold calls + custom audio
1460
+ upload are deferred to a future Phase B that adds multipart support to
1461
+ execute/engine.py.
1462
+ """
1463
+ cid = obj["canonical_id"]
1464
+ name = obj.get("source_name") or cid
1465
+ return [_op(cid, "configure", "music_on_hold",
1466
+ f"Track MoH source {name} (Phase A: no-op)")]
1467
+
1468
+
1469
+ def _expand_announcement(obj: dict[str, Any]) -> list[MigrationOp]:
1470
+ """Announcement → 1 op: upload (tier 1).
1471
+
1472
+ Phase A: the handler is a no-op placeholder. The AnnouncementMapper
1473
+ always creates AUDIO_ASSET_MANUAL decisions for every announcement,
1474
+ so operators already know to manually download and upload audio. The
1475
+ op is emitted so the deployment plan shows announcements as tracked.
1476
+ Real multipart POST /telephony/config/locations/{id}/announcements is
1477
+ deferred to Phase B.
1478
+ """
1479
+ cid = obj["canonical_id"]
1480
+ name = obj.get("name") or cid
1481
+ return [_op(cid, "upload", "announcement",
1482
+ f"Track announcement {name} (Phase A: manual upload required)")]
1483
+
1484
+
1485
+ # ---------------------------------------------------------------------------
1486
+ # Types that don't produce standalone operations
1487
+ # ---------------------------------------------------------------------------
1488
+
1489
+ _DATA_ONLY_TYPES = {
1490
+ "line": "Data consumed by user:create (extension) / workspace:assign_number",
1491
+ "voicemail_profile": "Data consumed by user:configure_voicemail",
1492
+ "e911_config": "Advisory only — ECBN handled by user:configure_settings, RedSky civic addresses are a separate workstream",
1493
+ }
1494
+
1495
+
1496
+ # ---------------------------------------------------------------------------
1497
+ # Location helper
1498
+ # ---------------------------------------------------------------------------
1499
+
1500
+ def _location_from_provenance(obj: dict[str, Any]) -> str | None:
1501
+ """Extract location_id from object data. Features may store it differently."""
1502
+ return obj.get("location_id")
1503
+
1504
+
1505
+ # ---------------------------------------------------------------------------
1506
+ # Expansion dispatch
1507
+ # All expanders take (obj_data, decisions) for uniformity.
1508
+ # ---------------------------------------------------------------------------
1509
+
1510
+
1511
+
1512
+ def _expand_receptionist_config(obj: dict) -> list:
1513
+ """Receptionist config -> 0-1 ops: configure (tier 6)."""
1514
+ cid = obj["canonical_id"]
1515
+ user_cid = obj.get("user_canonical_id")
1516
+ deps = [_node_id(user_cid, "create")] if user_cid else []
1517
+ for member_cid in obj.get("monitored_members", []):
1518
+ if member_cid:
1519
+ deps.append(_node_id(member_cid, "create"))
1520
+ loc_cid = obj.get("location_canonical_id")
1521
+ if loc_cid:
1522
+ deps.append(_node_id(loc_cid, "create"))
1523
+ return [_op(cid, "configure", "receptionist_config",
1524
+ f"Configure receptionist client for {cid}",
1525
+ depends_on=deps)]
1526
+
1527
+
1528
+ def _expand_executive_assistant(obj: dict[str, Any]) -> list[MigrationOp]:
1529
+ """Executive/assistant pairing → up to 7 ops.
1530
+
1531
+ Execution order (from spec §3g):
1532
+ 1. assign_executive_type — depends on executive user:create
1533
+ 2. assign_assistant_type — depends on all assistant user:create
1534
+ 3. assign_assistants — depends on steps 1 + 2 (both type ops)
1535
+ 4-7. configure_alert, configure_filtering, configure_screening,
1536
+ configure_assistant_settings — each depends on step 3
1537
+
1538
+ If the executive or all assistants are unresolved, the pairing has no
1539
+ users to act on and produces no ops. Individual unresolved assistants are
1540
+ silently skipped at handler time.
1541
+ (from 2026-04-10-executive-assistant-migration.md §4e)
1542
+ """
1543
+ cid = obj["canonical_id"]
1544
+ exec_cid = obj.get("executive_canonical_id")
1545
+ asst_cids = obj.get("assistant_canonical_ids") or []
1546
+
1547
+ if not exec_cid and not asst_cids:
1548
+ report = _current_report()
1549
+ if report is not None:
1550
+ _warn_skip(
1551
+ report,
1552
+ canonical_id=cid,
1553
+ entity_type="executive_assistant",
1554
+ reason="no_exec_no_assistants",
1555
+ consequence=(
1556
+ "exec/assistant pairing skipped (both executive and "
1557
+ "assistant lists are empty)"
1558
+ ),
1559
+ )
1560
+ return []
1561
+
1562
+ # Build dependencies for type-assignment ops
1563
+ exec_user_dep = [_node_id(exec_cid, "create")] if exec_cid else []
1564
+ asst_user_deps = [_node_id(a, "create") for a in asst_cids if a]
1565
+
1566
+ # Step 1: assign executive type
1567
+ assign_exec_type_node = _node_id(cid, "assign_executive_type")
1568
+ ops = [_op(cid, "assign_executive_type", "executive_assistant",
1569
+ f"Set EXECUTIVE type for {exec_cid or cid}",
1570
+ depends_on=exec_user_dep)]
1571
+
1572
+ # Step 2: assign assistant types (one op covers all assistants in the handler)
1573
+ assign_asst_type_node = _node_id(cid, "assign_assistant_type")
1574
+ ops.append(_op(cid, "assign_assistant_type", "executive_assistant",
1575
+ f"Set EXECUTIVE_ASSISTANT type for assistants of {cid}",
1576
+ depends_on=asst_user_deps))
1577
+
1578
+ # Step 3: pair assistants to executive — depends on both type assignments
1579
+ assign_assistants_node = _node_id(cid, "assign_assistants")
1580
+ ops.append(_op(cid, "assign_assistants", "executive_assistant",
1581
+ f"Assign assistants to executive {exec_cid or cid}",
1582
+ depends_on=[assign_exec_type_node, assign_asst_type_node]))
1583
+
1584
+ # Steps 4-7: post-pairing configuration — all depend on step 3
1585
+ post_deps = [assign_assistants_node]
1586
+
1587
+ ops.append(_op(cid, "configure_alert", "executive_assistant",
1588
+ f"Configure executive alert settings for {exec_cid or cid}",
1589
+ depends_on=post_deps))
1590
+
1591
+ if obj.get("filter_enabled"):
1592
+ ops.append(_op(cid, "configure_filtering", "executive_assistant",
1593
+ f"Configure executive call filtering for {exec_cid or cid}",
1594
+ depends_on=post_deps))
1595
+
1596
+ if obj.get("screening_enabled"):
1597
+ ops.append(_op(cid, "configure_screening", "executive_assistant",
1598
+ f"Configure executive call screening for {exec_cid or cid}",
1599
+ depends_on=post_deps))
1600
+
1601
+ if asst_cids:
1602
+ ops.append(_op(cid, "configure_assistant_settings", "executive_assistant",
1603
+ f"Configure assistant settings for {cid}",
1604
+ depends_on=post_deps))
1605
+
1606
+ return ops
1607
+
1608
+
1609
+ _EXPANDERS: dict[str, Any] = {
1610
+ "location": lambda obj, _: _expand_location(obj),
1611
+ "trunk": lambda obj, _: _expand_trunk(obj),
1612
+ "route_group": lambda obj, _: _expand_route_group(obj),
1613
+ "route_list": lambda obj, _: _expand_route_list(obj),
1614
+ "operating_mode": lambda obj, _: _expand_operating_mode(obj),
1615
+ "schedule": lambda obj, _: _expand_schedule(obj),
1616
+ "user": lambda obj, _: _expand_user(obj),
1617
+ "workspace": lambda obj, _: _expand_workspace(obj),
1618
+ "device": lambda obj, decs: _expand_device(obj, decs),
1619
+ "dial_plan": lambda obj, _: _expand_dial_plan(obj),
1620
+ "translation_pattern": lambda obj, _: _expand_translation_pattern(obj),
1621
+ "calling_permission": lambda obj, _: _expand_calling_permission(obj),
1622
+ "hunt_group": lambda obj, _: _expand_hunt_group(obj),
1623
+ "call_queue": lambda obj, _: _expand_call_queue(obj),
1624
+ "auto_attendant": lambda obj, _: _expand_auto_attendant(obj),
1625
+ "call_park": lambda obj, _: _expand_call_park(obj),
1626
+ "pickup_group": lambda obj, _: _expand_pickup_group(obj),
1627
+ "paging_group": lambda obj, _: _expand_paging_group(obj),
1628
+ "voicemail_group": lambda obj, _: _expand_voicemail_group(obj),
1629
+ "shared_line": lambda obj, decs: _expand_shared_line(obj, decs),
1630
+ "virtual_line": lambda obj, _: _expand_virtual_line(obj),
1631
+ "line_key_template": lambda obj, _: _expand_line_key_template(obj),
1632
+ "call_forwarding": lambda obj, _: _expand_call_forwarding(obj),
1633
+ "single_number_reach": lambda obj, _: _expand_single_number_reach(obj),
1634
+ "ecbn_config": lambda obj, _: _expand_ecbn_config(obj),
1635
+ "monitoring_list": lambda obj, _: _expand_monitoring_list(obj),
1636
+ "receptionist_config": lambda obj, _: _expand_receptionist_config(obj),
1637
+ "device_layout": lambda obj, _: _expand_device_layout(obj),
1638
+ "softkey_config": lambda obj, _: _expand_softkey_config(obj),
1639
+ "device_settings_template": lambda obj, d: _expand_device_settings_template(obj, d),
1640
+ "device_profile": lambda obj, _: _expand_device_profile(obj),
1641
+ "hoteling_location": lambda obj, _: _expand_hoteling_location(obj),
1642
+ "dect_network": lambda obj, _: _expand_dect_network(obj),
1643
+ "music_on_hold": lambda obj, _: _expand_music_on_hold(obj),
1644
+ "announcement": lambda obj, _: _expand_announcement(obj),
1645
+ "executive_assistant": lambda obj, _: _expand_executive_assistant(obj),
1646
+ }
1647
+
1648
+
1649
+ # ---------------------------------------------------------------------------
1650
+ # Main entry point
1651
+ # ---------------------------------------------------------------------------
1652
+
1653
+ def expand_to_operations(
1654
+ store: MigrationStore,
1655
+ bulk_device_threshold: int | None = None,
1656
+ skip_rebuild_phones: bool = False,
1657
+ config: dict | None = None,
1658
+ report: PlannerSkipReport | None = None,
1659
+ fail_on_unresolved: bool | None = None,
1660
+ ) -> list[MigrationOp]:
1661
+ """Expand each analyzed canonical object into its constituent API operations.
1662
+
1663
+ Only objects at status 'analyzed' are expanded. Objects at 'needs_decision'
1664
+ are skipped — they have pending decisions that must be resolved first.
1665
+
1666
+ Generic skip: any object with a resolved "skip" decision of type
1667
+ DEVICE_INCOMPATIBLE, EXTENSION_CONFLICT, or LOCATION_AMBIGUOUS is
1668
+ suppressed entirely (no ops produced).
1669
+
1670
+ Special handling: SHARED_LINE_COMPLEX decisions are checked inside
1671
+ the shared_line expander (supports "virtual_line" option in addition
1672
+ to "skip").
1673
+
1674
+ Silent-skip visibility (2026-04-15): every short-circuit path emits a
1675
+ ``logger.warning`` AND records a ``PlannerSkipEntry`` into ``report`` (or
1676
+ a local report, if ``report`` is None). The aggregate summary is logged
1677
+ before returning. This guards against the ``DEVICE_FIRMWARE_CONVERTIBLE``
1678
+ class of bug where a stale decision silently excluded entities from the
1679
+ plan.
1680
+
1681
+ Args:
1682
+ store: The migration store to read analyzed objects from.
1683
+ bulk_device_threshold: When the number of unique devices being created
1684
+ meets or exceeds this value, replace per-device settings/layout/
1685
+ softkey ops with bulk submission ops via ``_optimize_for_bulk``.
1686
+ When ``None``, uses ``BULK_DEVICE_THRESHOLD_DEFAULT``.
1687
+ config: Project config dict (from config.json). When present, consulted
1688
+ by device expander for ``convertible_provisioning`` mode.
1689
+ report: Optional caller-supplied ``PlannerSkipReport``. When omitted,
1690
+ a local report is created; callers that want to inspect the
1691
+ roll-up pass one in and read it after the call returns.
1692
+ fail_on_unresolved: When True, raise ``PlannerUnresolvedError`` at
1693
+ the end of expansion if any entity was skipped due to a stale
1694
+ or pending decision. When ``None`` (default), consults the
1695
+ ``WXCLI_PLAN_FAIL_ON_UNRESOLVED`` environment variable (any
1696
+ truthy string enables the gate).
1697
+
1698
+ (from 05-dependency-graph.md lines 38-75,
1699
+ phase-07-planning.md lines 26-27)
1700
+ """
1701
+ # Resolve fail_on_unresolved from env if caller left it unset.
1702
+ if fail_on_unresolved is None:
1703
+ env = os.environ.get("WXCLI_PLAN_FAIL_ON_UNRESOLVED", "").strip().lower()
1704
+ fail_on_unresolved = env in ("1", "true", "yes", "on")
1705
+
1706
+ # Install the skip report for the duration of this call so expanders
1707
+ # can call _current_report() without threading it through every kwarg.
1708
+ # ContextVar.set() returns a Token that we pass to reset() in finally —
1709
+ # this restores the prior context value (supports nested calls / threads /
1710
+ # asyncio tasks without clobbering).
1711
+ if report is None:
1712
+ report = PlannerSkipReport()
1713
+ _ctx_token = _set_current_report(report)
1714
+
1715
+ analyzed_objects = store.query_by_status("analyzed")
1716
+ all_ops: list[MigrationOp] = []
1717
+ skipped_data_only = 0
1718
+ skipped_by_decision = 0
1719
+
1720
+ # Build decision indexes once (avoids O(objects * decisions) repeated queries)
1721
+ decisions_index = _build_decisions_index(store)
1722
+ stale_index = _build_stale_decisions_index(store)
1723
+ pending_index = _build_pending_decisions_index(store)
1724
+
1725
+ try:
1726
+ for obj in analyzed_objects:
1727
+ obj_data = obj.model_dump()
1728
+ cid = obj_data["canonical_id"]
1729
+ # Derive object_type from canonical_id prefix (e.g., "user:0001" → "user")
1730
+ obj_type = cid.split(":")[0] if ":" in cid else "unknown"
1731
+
1732
+ # Data-only types don't produce operations — log per-object so
1733
+ # operators can see exactly which lines / voicemail profiles / etc.
1734
+ # were consumed elsewhere vs. dropped by accident.
1735
+ if obj_type in _DATA_ONLY_TYPES:
1736
+ skipped_data_only += 1
1737
+ logger.debug(
1738
+ "Planner skip (expected): %s %s — data-only type (%s)",
1739
+ obj_type, cid, _DATA_ONLY_TYPES[obj_type],
1740
+ )
1741
+ report.record(
1742
+ canonical_id=cid,
1743
+ entity_type=obj_type,
1744
+ reason="data_only_type",
1745
+ consequence=_DATA_ONLY_TYPES[obj_type],
1746
+ )
1747
+ continue
1748
+
1749
+ expander = _EXPANDERS.get(obj_type)
1750
+ if expander is None:
1751
+ # Unknown expander — loud WARN, record so summary shows it.
1752
+ _warn_skip(
1753
+ report,
1754
+ canonical_id=cid,
1755
+ entity_type=obj_type,
1756
+ reason="no_expander_registered",
1757
+ consequence=(
1758
+ f"no planner expansion pattern for type '{obj_type}' — "
1759
+ "entity will NOT be provisioned (add to _EXPANDERS)"
1760
+ ),
1761
+ )
1762
+ continue
1763
+
1764
+ # Stale decisions on an analyzed object are the DEVICE_FIRMWARE_CONVERTIBLE
1765
+ # bug pattern — analyzer rewrote fingerprints, the old decision got
1766
+ # stale-marked, and the planner previously dropped the entity without
1767
+ # logging. Detect and WARN so this class of bug is loud going forward.
1768
+ stale_decisions = stale_index.get(cid, [])
1769
+ for sd in stale_decisions:
1770
+ _warn_skip(
1771
+ report,
1772
+ canonical_id=cid,
1773
+ entity_type=obj_type,
1774
+ reason="stale_decision_attached",
1775
+ decision_type=sd.get("type"),
1776
+ decision_state="stale",
1777
+ consequence=(
1778
+ f"{obj_type} has a stale {sd.get('type')} decision — "
1779
+ "re-running analyze may have invalidated this decision; "
1780
+ "inspect with `wxcli cucm decisions` and re-resolve if needed"
1781
+ ),
1782
+ )
1783
+
1784
+ # Pending decisions on an analyzed object indicate a status-update
1785
+ # bug (the object should be at 'needs_decision', not 'analyzed').
1786
+ # WARN loudly so this drift is caught.
1787
+ pending_decisions = pending_index.get(cid, [])
1788
+ for pd in pending_decisions:
1789
+ _warn_skip(
1790
+ report,
1791
+ canonical_id=cid,
1792
+ entity_type=obj_type,
1793
+ reason="pending_decision_on_analyzed_object",
1794
+ decision_type=pd.get("type"),
1795
+ decision_state="pending",
1796
+ consequence=(
1797
+ f"{obj_type} is status=analyzed but has a pending "
1798
+ f"{pd.get('type')} decision — fix analyzer status logic "
1799
+ "and re-run `wxcli cucm plan`"
1800
+ ),
1801
+ )
1802
+
1803
+ decisions = decisions_index.get(cid, [])
1804
+
1805
+ # Generic skip: any _SKIP_DECISION_TYPES resolved as "skip" → suppress object
1806
+ skip_decision = _find_skip_decision(decisions)
1807
+ if skip_decision is not None:
1808
+ skipped_by_decision += 1
1809
+ _warn_skip(
1810
+ report,
1811
+ canonical_id=cid,
1812
+ entity_type=obj_type,
1813
+ reason="decision_skip",
1814
+ decision_type=skip_decision.get("type"),
1815
+ decision_state="skip",
1816
+ consequence=(
1817
+ f"{obj_type} will not be provisioned to Webex "
1818
+ f"(operator chose 'skip' for {skip_decision.get('type')})"
1819
+ ),
1820
+ )
1821
+ continue
1822
+
1823
+ # Patch location_id from resolved LOCATION_AMBIGUOUS decision
1824
+ # (e.g., trunk with no device pool where user picked a location)
1825
+ loc_choice = _decision_chosen(decisions, "LOCATION_AMBIGUOUS")
1826
+ if loc_choice and loc_choice != "skip" and not obj_data.get("location_id"):
1827
+ obj_data["location_id"] = loc_choice
1828
+ # Persist the patched location_id back to the store object
1829
+ if hasattr(obj, "location_id"):
1830
+ obj.location_id = loc_choice
1831
+ store.upsert_object(obj)
1832
+
1833
+ # Device expander takes an optional config dict for convertible_provisioning mode.
1834
+ if obj_type == "device":
1835
+ ops = _expand_device(obj_data, decisions, config=config)
1836
+ else:
1837
+ ops = expander(obj_data, decisions)
1838
+ all_ops.extend(ops)
1839
+
1840
+ # ---- Aggregate summary ----
1841
+ logger.info(
1842
+ "Expanded %d analyzed objects into %d operations "
1843
+ "(%d data-only skipped, %d skipped by decision)",
1844
+ len(analyzed_objects) - skipped_data_only - skipped_by_decision,
1845
+ len(all_ops),
1846
+ skipped_data_only,
1847
+ skipped_by_decision,
1848
+ )
1849
+
1850
+ # Entities at status='needs_decision' never reach expansion above,
1851
+ # so they don't appear in ``report.entries``. Attach a separate
1852
+ # count-by-decision-type summary so operators can see what's held
1853
+ # back awaiting their input.
1854
+ report.needs_decision_counts = _count_needs_decision_by_type(store)
1855
+
1856
+ _log_skip_summary(report)
1857
+ _log_needs_decision_summary(report)
1858
+
1859
+ # Post-expansion bulk optimization pass.
1860
+ if bulk_device_threshold is None:
1861
+ bulk_device_threshold = BULK_DEVICE_THRESHOLD_DEFAULT
1862
+ all_ops = _optimize_for_bulk(all_ops, store, bulk_device_threshold,
1863
+ skip_rebuild_phones=skip_rebuild_phones)
1864
+
1865
+ # Loud-fail gate: raise if requested and any unresolved skips occurred.
1866
+ if fail_on_unresolved and report.has_unresolved_skips:
1867
+ unresolved = report.unresolved_entries()
1868
+ types = sorted({e.decision_type or e.reason for e in unresolved})
1869
+ raise PlannerUnresolvedError(
1870
+ f"Planner aborted: {len(unresolved)} entities skipped due to "
1871
+ f"unresolved decisions ({', '.join(types)}). "
1872
+ "Re-run `wxcli cucm decisions` to resolve, or rerun `wxcli cucm plan` "
1873
+ "without `--fail-on-unresolved` to accept the skips."
1874
+ )
1875
+
1876
+ return all_ops
1877
+ finally:
1878
+ _CURRENT_REPORT.reset(_ctx_token)
1879
+
1880
+
1881
+ def _log_skip_summary(report: PlannerSkipReport) -> None:
1882
+ """Log an aggregate roll-up of every planner skip.
1883
+
1884
+ Example output::
1885
+
1886
+ Planner skipped 23 entities due to unresolved decisions:
1887
+ DEVICE_INCOMPATIBLE: 15 skipped (decision=skip)
1888
+ USER_EXTENSION_CONFLICT: 5 unresolved (decision=stale)
1889
+ HUNT_GROUP_MISSING_OWNER: 3 unresolved (decision=pending)
1890
+ Review with: wxcli cucm decisions --type <type> -p <project>
1891
+ """
1892
+ if not report.entries:
1893
+ return
1894
+
1895
+ total = len(report.entries)
1896
+ logger.warning("Planner skipped %d entities total:", total)
1897
+ # Group by (reason_or_decision_type, decision_state)
1898
+ groups: dict[tuple[str, str], int] = {}
1899
+ for e in report.entries:
1900
+ key = (e.decision_type or e.reason, e.decision_state or "no_decision")
1901
+ groups[key] = groups.get(key, 0) + 1
1902
+ # Emit groups in deterministic, caller-friendly order
1903
+ for (key, state), cnt in sorted(groups.items()):
1904
+ state_note = f"decision={state}" if state != "no_decision" else "expander short-circuit"
1905
+ logger.warning(" %s: %d skipped (%s)", key, cnt, state_note)
1906
+ if report.has_unresolved_skips:
1907
+ logger.warning(
1908
+ " Review unresolved decisions with: "
1909
+ "wxcli cucm decisions --type <type> -p <project>"
1910
+ )
1911
+
1912
+
1913
+ def _log_needs_decision_summary(report: PlannerSkipReport) -> None:
1914
+ """Log entities held back at status='needs_decision' by decision type.
1915
+
1916
+ These entities don't appear in ``_log_skip_summary`` because they never
1917
+ reach the planner's expansion loop — they're held back waiting for
1918
+ operator input. This surfaces them so operators know the plan isn't
1919
+ the full picture.
1920
+
1921
+ Silent if ``report.needs_decision_counts`` is empty.
1922
+
1923
+ Example output::
1924
+
1925
+ 17 entities held back awaiting your decision:
1926
+ DEVICE_LABEL_CONFLICT: 12 entities
1927
+ USER_EXTENSION_CONFLICT: 5 entities
1928
+ Review with: wxcli cucm decisions --status pending -p <project>
1929
+ """
1930
+ counts = report.needs_decision_counts
1931
+ if not counts:
1932
+ return
1933
+ total = sum(counts.values())
1934
+ logger.warning("%d entities held back awaiting your decision:", total)
1935
+ for dtype, cnt in sorted(counts.items()):
1936
+ logger.warning(" %s: %d entities", dtype, cnt)
1937
+ logger.warning(
1938
+ " Review with: wxcli cucm decisions --status pending -p <project>"
1939
+ )