@salesforce/afv-skills 1.47.0 → 1.49.0

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 (160) hide show
  1. package/package.json +1 -1
  2. package/skills/education-cloud-domain-configure/SKILL.md +234 -0
  3. package/skills/education-cloud-domain-configure/references/dependent-features.md +198 -0
  4. package/skills/education-cloud-domain-configure/references/domain-features/academic-operations.md +386 -0
  5. package/skills/education-cloud-domain-configure/references/domain-features/alumni-relations.md +155 -0
  6. package/skills/education-cloud-domain-configure/references/domain-features/cross-domain-dependencies.md +18 -0
  7. package/skills/education-cloud-domain-configure/references/domain-features/industriessettings-toggle-reference.md +43 -0
  8. package/skills/education-cloud-domain-configure/references/domain-features/mentoring.md +198 -0
  9. package/skills/education-cloud-domain-configure/references/domain-features/recruitment-admissions.md +315 -0
  10. package/skills/education-cloud-domain-configure/references/domain-features/student-management.md +155 -0
  11. package/skills/education-cloud-domain-configure/references/domain-features/student-success.md +249 -0
  12. package/skills/education-cloud-domain-configure/references/domain-features.md +27 -0
  13. package/skills/education-cloud-domain-configure/references/gotchas-extended.md +23 -0
  14. package/skills/education-cloud-domain-configure/references/toggle-write-path.md +67 -0
  15. package/skills/education-cloud-domain-configure/scripts/check_license_provisioned.py +62 -0
  16. package/skills/education-cloud-domain-configure/scripts/check_org_edition.py +44 -0
  17. package/skills/education-cloud-domain-configure/scripts/check_permission_assigned.py +39 -0
  18. package/skills/education-cloud-multi-campus-configure/SKILL.md +2 -0
  19. package/skills/education-cloud-multi-campus-configure/references/foundation_prerequisites.md +4 -3
  20. package/skills/service-agentforce-channel-configure/SKILL.md +33 -28
  21. package/skills/service-agentforce-human-escalation-configure/SKILL.md +205 -0
  22. package/skills/service-agentforce-human-escalation-configure/assets/escalation-thresholds.instructions.md +37 -0
  23. package/skills/service-agentforce-human-escalation-configure/assets/force-app/main/default/flows/Human_Escalation_Outbound_Flow.flow-meta.xml +173 -0
  24. package/skills/service-agentforce-human-escalation-configure/assets/package.xml +8 -0
  25. package/skills/service-agentforce-human-escalation-configure/assets/sfdx-project.json +5 -0
  26. package/skills/service-agentforce-human-escalation-configure/references/classifier-contracts.md +103 -0
  27. package/skills/service-agentforce-human-escalation-configure/references/escalation-mechanism.md +63 -0
  28. package/skills/service-agentforce-human-escalation-configure/references/failure-policy.md +44 -0
  29. package/skills/service-agentforce-human-escalation-configure/references/nga-escalation.md +57 -0
  30. package/skills/service-agentforce-human-escalation-configure/references/runtime-verification.md +72 -0
  31. package/skills/service-agentforce-human-escalation-configure/scripts/classify-agent-active.mjs +49 -0
  32. package/skills/service-agentforce-human-escalation-configure/scripts/classify-nga-escalation.mjs +166 -0
  33. package/skills/service-agentforce-human-escalation-configure/scripts/extract-outbound-route.mjs +67 -0
  34. package/skills/service-agentforce-human-escalation-configure/scripts/patch-escalation-surfaces.mjs +131 -0
  35. package/skills/service-agentforce-human-escalation-configure/scripts/tests/__init__.py +0 -0
  36. package/skills/service-agentforce-human-escalation-configure/scripts/tests/_bootstrap.py +249 -0
  37. package/skills/service-agentforce-human-escalation-configure/scripts/tests/test_escalation_contracts.py +899 -0
  38. package/skills/service-agentforce-human-escalation-configure/scripts/verify-and-configure.sh +705 -0
  39. package/skills/service-agentforce-human-escalation-configure/scripts/verify-escalation-config.mjs +89 -0
  40. package/skills/service-de-channel-routing-configure/SKILL.md +13 -5
  41. package/skills/service-de-channel-routing-configure/scripts/tests/test_skill_contract.py +25 -0
  42. package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +23 -10
  43. package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +63 -48
  44. package/skills/service-itsm-agentic-setup-agentforce-coordinate/scripts/verify-child-verdict.mjs +6 -2
  45. package/skills/service-omni-agent-users-create/SKILL.md +96 -0
  46. package/skills/service-omni-agent-users-create/assets/create-users.apex.template +80 -0
  47. package/skills/service-omni-agent-users-create/references/apex-patterns.md +135 -0
  48. package/skills/service-omni-agent-users-create/references/apex-template-notes.md +40 -0
  49. package/skills/service-omni-agent-users-create/scripts/detect-and-create.sh +206 -0
  50. package/skills/service-omni-agent-users-create/scripts/detect-existing.sh +122 -0
  51. package/skills/service-omni-agent-users-create/scripts/run-create.sh +298 -0
  52. package/skills/service-omni-agent-users-create/scripts/tests/test_user_create_security.py +171 -0
  53. package/skills/service-omni-agent-work-sharing-configure/SKILL.md +78 -0
  54. package/skills/service-omni-agent-work-sharing-configure/references/api-notes.md +31 -0
  55. package/skills/service-omni-agent-work-sharing-configure/scripts/configure-and-report.sh +186 -0
  56. package/skills/service-omni-agent-work-sharing-configure/scripts/tests/_bootstrap.py +120 -0
  57. package/skills/service-omni-agent-work-sharing-configure/scripts/tests/test_agent_work_sharing_contracts.py +93 -0
  58. package/skills/service-omni-base-settings-configure/SKILL.md +77 -0
  59. package/skills/service-omni-base-settings-configure/assets/force-app/main/default/settings/OmniChannel.settings-meta.xml +8 -0
  60. package/skills/service-omni-base-settings-configure/assets/package.xml +8 -0
  61. package/skills/service-omni-base-settings-configure/assets/sfdx-project.json +12 -0
  62. package/skills/service-omni-base-settings-configure/references/api-notes.md +121 -0
  63. package/skills/service-omni-base-settings-configure/scripts/configure-and-report.sh +164 -0
  64. package/skills/service-omni-base-settings-configure/scripts/detect-existing.sh +69 -0
  65. package/skills/service-omni-channel-setup-coordinate/SKILL.md +168 -0
  66. package/skills/service-omni-channel-setup-coordinate/examples/output-templates.md +161 -0
  67. package/skills/service-omni-channel-setup-coordinate/references/gap-catalog.md +99 -0
  68. package/skills/service-omni-channel-setup-coordinate/scripts/integration-driver.sh +1542 -0
  69. package/skills/service-omni-channel-setup-coordinate/scripts/tests/__init__.py +0 -0
  70. package/skills/service-omni-channel-setup-coordinate/scripts/tests/_bootstrap.py +223 -0
  71. package/skills/service-omni-channel-setup-coordinate/scripts/tests/test_omni_contracts.py +804 -0
  72. package/skills/service-omni-command-center-analyze/SKILL.md +84 -0
  73. package/skills/service-omni-command-center-analyze/references/api-notes.md +54 -0
  74. package/skills/service-omni-command-center-analyze/scripts/analyze.sh +198 -0
  75. package/skills/service-omni-command-center-analyze/scripts/tests/_bootstrap.py +115 -0
  76. package/skills/service-omni-command-center-analyze/scripts/tests/test_command_center_analyze_contracts.py +165 -0
  77. package/skills/service-omni-permission-set-assign/SKILL.md +88 -0
  78. package/skills/service-omni-permission-set-assign/assets/package.xml +8 -0
  79. package/skills/service-omni-permission-set-assign/references/api-notes.md +143 -0
  80. package/skills/service-omni-permission-set-assign/scripts/verify-and-assign.sh +397 -0
  81. package/skills/service-omni-presence-status-deploy/SKILL.md +87 -0
  82. package/skills/service-omni-presence-status-deploy/assets/package.xml +12 -0
  83. package/skills/service-omni-presence-status-deploy/references/api-notes.md +115 -0
  84. package/skills/service-omni-presence-status-deploy/scripts/deploy-and-report.sh +422 -0
  85. package/skills/service-omni-presence-user-config-deploy/SKILL.md +80 -0
  86. package/skills/service-omni-presence-user-config-deploy/references/api-notes.md +60 -0
  87. package/skills/service-omni-presence-user-config-deploy/scripts/deploy-and-report.sh +261 -0
  88. package/skills/service-omni-presence-user-config-deploy/scripts/tests/_bootstrap.py +106 -0
  89. package/skills/service-omni-presence-user-config-deploy/scripts/tests/test_presence_user_config_contracts.py +142 -0
  90. package/skills/service-omni-queue-deploy/SKILL.md +91 -0
  91. package/skills/service-omni-queue-deploy/references/api-notes.md +140 -0
  92. package/skills/service-omni-queue-deploy/scripts/verify-and-align.sh +470 -0
  93. package/skills/service-omni-queue-members-assign/SKILL.md +93 -0
  94. package/skills/service-omni-queue-members-assign/references/api-notes.md +144 -0
  95. package/skills/service-omni-queue-members-assign/scripts/tests/_bootstrap.py +67 -0
  96. package/skills/service-omni-queue-members-assign/scripts/tests/test_queue_members_contracts.py +73 -0
  97. package/skills/service-omni-queue-members-assign/scripts/verify-and-bind.sh +362 -0
  98. package/skills/service-omni-queue-routing-config-deploy/SKILL.md +101 -0
  99. package/skills/service-omni-queue-routing-config-deploy/references/api-notes.md +127 -0
  100. package/skills/service-omni-queue-routing-config-deploy/scripts/tests/_bootstrap.py +90 -0
  101. package/skills/service-omni-queue-routing-config-deploy/scripts/tests/test_queue_routing_config_contracts.py +70 -0
  102. package/skills/service-omni-queue-routing-config-deploy/scripts/upsert-and-report.sh +538 -0
  103. package/skills/service-omni-routing-flow-deploy/SKILL.md +111 -0
  104. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Case_Trigger.SkillsBased.flow-meta.xml +94 -0
  105. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Case_Trigger.flow-meta.xml +88 -0
  106. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Cases.flow-meta.xml +144 -0
  107. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCall_Trigger.SkillsBased.flow-meta.xml +94 -0
  108. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCall_Trigger.flow-meta.xml +88 -0
  109. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCalls.flow-meta.xml +144 -0
  110. package/skills/service-omni-routing-flow-deploy/assets/package.xml +11 -0
  111. package/skills/service-omni-routing-flow-deploy/assets/sfdx-project.json +11 -0
  112. package/skills/service-omni-routing-flow-deploy/references/api-notes.md +265 -0
  113. package/skills/service-omni-routing-flow-deploy/scripts/deploy-and-report.sh +684 -0
  114. package/skills/service-omni-service-channel-configure/SKILL.md +83 -0
  115. package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/Cases.serviceChannel-meta.xml +9 -0
  116. package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/Incidents.serviceChannel-meta.xml +9 -0
  117. package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/MessagingSessions.serviceChannel-meta.xml +9 -0
  118. package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/VoiceCalls.serviceChannel-meta.xml +9 -0
  119. package/skills/service-omni-service-channel-configure/assets/package.xml +11 -0
  120. package/skills/service-omni-service-channel-configure/assets/sfdx-project.json +12 -0
  121. package/skills/service-omni-service-channel-configure/references/api-notes.md +84 -0
  122. package/skills/service-omni-service-channel-configure/scripts/deploy-and-report.sh +383 -0
  123. package/skills/service-omni-sidebar-configure/SKILL.md +77 -0
  124. package/skills/service-omni-sidebar-configure/references/api-notes.md +51 -0
  125. package/skills/service-omni-sidebar-configure/scripts/enable-and-report.sh +315 -0
  126. package/skills/service-omni-sidebar-configure/scripts/tests/_bootstrap.py +142 -0
  127. package/skills/service-omni-sidebar-configure/scripts/tests/test_sidebar_contracts.py +139 -0
  128. package/skills/service-omni-skills-based-routing-configure/SKILL.md +81 -0
  129. package/skills/service-omni-skills-based-routing-configure/references/api-notes.md +50 -0
  130. package/skills/service-omni-skills-based-routing-configure/scripts/configure-and-report.sh +270 -0
  131. package/skills/service-omni-skills-based-routing-configure/scripts/tests/_bootstrap.py +120 -0
  132. package/skills/service-omni-skills-based-routing-configure/scripts/tests/test_sbr_contracts.py +129 -0
  133. package/skills/service-omni-supervisor-config-deploy/SKILL.md +97 -0
  134. package/skills/service-omni-supervisor-config-deploy/assets/force-app/main/default/omniSupervisorConfigs/Omni_Supervisor.omniSupervisorConfig-meta.xml +9 -0
  135. package/skills/service-omni-supervisor-config-deploy/assets/package.xml +8 -0
  136. package/skills/service-omni-supervisor-config-deploy/assets/sfdx-project.json +12 -0
  137. package/skills/service-omni-supervisor-config-deploy/references/api-notes.md +205 -0
  138. package/skills/service-omni-supervisor-config-deploy/scripts/deploy-and-report.sh +586 -0
  139. package/skills/service-omni-supervisor-config-deploy/scripts/tests/_bootstrap.py +153 -0
  140. package/skills/service-omni-supervisor-config-deploy/scripts/tests/test_supervisor_config_contracts.py +116 -0
  141. package/skills/service-omni-supervisor-permset-assign/SKILL.md +88 -0
  142. package/skills/service-omni-supervisor-permset-assign/references/api-notes.md +48 -0
  143. package/skills/service-omni-supervisor-permset-assign/scripts/verify-and-assign.sh +316 -0
  144. package/skills/service-omni-supervisor-surface-deploy/SKILL.md +85 -0
  145. package/skills/service-omni-supervisor-surface-deploy/references/api-notes.md +47 -0
  146. package/skills/service-omni-supervisor-surface-deploy/scripts/deploy-and-report.sh +216 -0
  147. package/skills/service-omni-supervisor-surface-deploy/scripts/tests/_bootstrap.py +95 -0
  148. package/skills/service-omni-supervisor-surface-deploy/scripts/tests/test_supervisor_surface_contracts.py +154 -0
  149. package/skills/service-omni-supervisor-users-create/SKILL.md +92 -0
  150. package/skills/service-omni-supervisor-users-create/assets/create-supervisors.apex.template +72 -0
  151. package/skills/service-omni-supervisor-users-create/references/apex-patterns.md +73 -0
  152. package/skills/service-omni-supervisor-users-create/references/apex-template-notes.md +41 -0
  153. package/skills/service-omni-supervisor-users-create/scripts/detect-and-create.sh +197 -0
  154. package/skills/service-omni-supervisor-users-create/scripts/detect-existing.sh +112 -0
  155. package/skills/service-omni-supervisor-users-create/scripts/run-create.sh +296 -0
  156. package/skills/service-omni-work-skill-routing-configure/SKILL.md +80 -0
  157. package/skills/service-omni-work-skill-routing-configure/references/api-notes.md +67 -0
  158. package/skills/service-omni-work-skill-routing-configure/scripts/configure-and-report.sh +262 -0
  159. package/skills/service-omni-work-skill-routing-configure/scripts/tests/_bootstrap.py +121 -0
  160. package/skills/service-omni-work-skill-routing-configure/scripts/tests/test_work_skill_routing_contracts.py +161 -0
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: service-omni-permission-set-assign
3
+ description: "Assign the required Omni permission sets to provisioned agent users. TRIGGER when users ask to assign Omni permissions to agents, grant Omni-Channel access, add Omni_Agent permissions, repair missing Omni permission assignments, or prepare agents to use the Omni widget. DO NOT TRIGGER for arbitrary permission-set authoring, agent creation, or queue membership."
4
+ allowed-tools: Bash Read Write Edit Glob Grep
5
+ metadata:
6
+ version: "1.0"
7
+ domains: ["Service"]
8
+ minApiVersion: "66.0"
9
+ relatedSkills:
10
+ - "service-omni-agent-users-create"
11
+ - "service-omni-presence-status-deploy"
12
+ - "service-omni-queue-members-assign"
13
+ accessCheck:
14
+ - type: license
15
+ value: ServiceCloud
16
+ cliTools:
17
+ - tool: ["jq"]
18
+ semver: ">=1.6"
19
+ - tool: ["sf"]
20
+ semver: ">=2.139.6"
21
+ ---
22
+
23
+ # service-omni-permission-set-assign
24
+
25
+ Assign one or more PermissionSets to N agent users via `PermissionSetAssignment` Data API POSTs. The default target is `Omni_Agent`, the permission set granting the OmniChannel widget, presence-status access, and demo-queue visibility — without an assignment row, agents cannot open the widget or receive routed work. Detection is SOQL-based, so the skill only creates the assignments that are missing. Agent users come from `service-omni-agent-users-create`, and it runs alongside `service-omni-presence-status-deploy` so assigned agents can both open the widget and select a status.
26
+
27
+ ## Inputs
28
+
29
+ ```bash
30
+ bash scripts/verify-and-assign.sh <org-alias> [count=3] [permission-set-names-csv=Omni_Agent]
31
+ ```
32
+
33
+ - `org-alias` (required).
34
+ - `count` (optional, default `3`, range `1..10`) — must match the agent user count.
35
+ - `permission-set-names-csv` (optional, default `Omni_Agent`) — comma-separated for multiple. Every user gets every listed set (cross-product): `count=3` × 2 sets = up to 6 assignments.
36
+
37
+ ## Preconditions and safety
38
+
39
+ - Target org authenticated via `sf` CLI, Service Cloud license, `sf` CLI ≥ 2.139.6.
40
+ - The agent users exist (`service-omni-agent-users-create`); a count mismatch blocks with a pointer back to that skill.
41
+ - The executing user has `PermissionsAssignPermissionSets` (standard on System Administrator) — required even for org admins; `ModifyAllData` alone is insufficient.
42
+ - At least one Omni presence status exists before `Omni_Agent` self-heals — the bundled set grants agent capability via `servicePresenceStatusAccesses` for whichever curated statuses exist (e.g. `Available_Case`/`Available_Voice` + `Busy`), generated at deploy time. Run `service-omni-presence-status-deploy` first; the coordinator sequences presence before permset for this reason.
43
+ - The three-way `safe_to_write` guard applies — assigning permission sets on a production org can escalate a real user's privileges, so it blocks with no override.
44
+
45
+ **Self-heal (run mode only).** When the default `Omni_Agent` set is absent, the skill deploys the bundled `Omni_Agent` metadata once, then assigns. In `--plan` mode it never deploys — it reports `action_needed` and exits read-only. This covers only the bundled `Omni_Agent` asset; any other permission set must already exist or the run blocks with a click-path.
46
+
47
+ ## Run
48
+
49
+ `verify-and-assign.sh` performs the whole cycle:
50
+
51
+ 1. Compute `safe_to_write`; derive the 8-char org suffix.
52
+ 2. Resolve the agent users by the `agent{1..N}.<suffix>@example.com` pattern; block if any are missing.
53
+ 3. Resolve each `PermissionSet` by name; self-heal `Omni_Agent` if absent, else block naming which is missing.
54
+ 4. Query existing `PermissionSetAssignment` for the (user × set) cross-product; compute the missing pairs.
55
+ 5. POST one assignment per missing pair (individual POSTs, no `allOrNone`).
56
+ 6. Re-query to confirm final state and emit the report.
57
+
58
+ ## Behavior
59
+
60
+ **Cross-product.** Every user gets every listed set; a partial assignment is a failure, not a feature.
61
+
62
+ **Idempotency.** `PermissionSetAssignment` has a database uniqueness constraint on (AssigneeId, PermissionSetId), so a re-POST raises `DUPLICATE_VALUE`; the skill detects existing pairs first and treats `DUPLICATE_VALUE` as a safety net for concurrent races. It POSTs individually so one duplicate or error never rolls back its successful siblings, and it re-queries after all POSTs — a 201 only means Salesforce accepted the write; a subsequent SOQL confirms the assignment is active.
63
+
64
+ **Non-destructive.** The skill is create-only; it never deletes existing assignments (users may hold out-of-band permissions from other admins) and derives users from the agent pattern rather than accepting an explicit user-id list, so it never assigns demo permissions to real named users.
65
+
66
+ ## Output contract
67
+
68
+ A single JSON object with `status` ∈ `assigned` | `reused` | `partial` | `blocked`, the resolved `permission_sets`, `org_suffix`, `requested_count`, `expected_assignment_count` (= `requested_count × len(permission_sets)`), a `before` snapshot, `assigned_this_run`/`assigned_count`, `reused_count`, an `after` snapshot, `manual_actions`, and `blocking_issue`.
69
+
70
+ - `assigned` — at least one new assignment created; all expected pairs exist after.
71
+ - `reused` — all expected pairs already existed; nothing POSTed.
72
+ - `partial` — some POSTs failed; final count is below expected.
73
+ - `blocked` — precondition failed (production org, missing set, missing users, missing permissions).
74
+
75
+ `assigned_count + reused_count == expected_assignment_count` unless `partial`; `blocking_issue` is non-null only for `blocked`/`partial`.
76
+
77
+ ## Limitations
78
+
79
+ - Assigns individual PermissionSets only — a `PermissionSetGroup` is a different sObject and is out of scope.
80
+ - Self-heal covers only the bundled `Omni_Agent`; it is not a general-purpose permission-set authoring surface.
81
+ - Create-only; it does not remove assignments.
82
+
83
+ ## References
84
+
85
+ | File | When to read |
86
+ |---|---|
87
+ | `assets/package.xml` | Load when the default `Omni_Agent` permission set is missing and the run-mode self-heal path must deploy the bundled metadata |
88
+ | `references/api-notes.md` | Before the POST loop — PermissionSetAssignment schema, its `DUPLICATE_VALUE` semantics, and why users are derived from the agent pattern rather than an explicit id list |
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <Package xmlns="http://soap.sforce.com/2006/04/metadata">
3
+ <types>
4
+ <members>Omni_Agent</members>
5
+ <name>PermissionSet</name>
6
+ </types>
7
+ <version>66.0</version>
8
+ </Package>
@@ -0,0 +1,143 @@
1
+ # API notes — PermissionSetAssignment (v66)
2
+
3
+ ## Schema
4
+
5
+ `PermissionSetAssignment` is a standard sObject that links a `User` (or `Group`) to a `PermissionSet` or `PermissionSetGroup`. Every "user has permission set X" relationship is one row in this table.
6
+
7
+ Fields we care about:
8
+
9
+ | Field | Type | Purpose |
10
+ |---|---|---|
11
+ | `Id` | 18-char Id (prefix `0Pa`) | The assignment row's Id; used for teardown |
12
+ | `AssigneeId` | Reference (`User.Id`) | The user receiving the assignment |
13
+ | `PermissionSetId` | Reference (`PermissionSet.Id`) | The specific PermissionSet being granted |
14
+ | `PermissionSetGroupId` | Reference (`PermissionSetGroup.Id`) | Alternative to PermissionSetId — for PSGroup assignments. Mutually exclusive with PermissionSetId. Not used by this skill. |
15
+ | `IsActive` | Boolean | Not directly writeable; managed by Salesforce |
16
+ | `SystemModstamp` | Datetime | Server-side write timestamp |
17
+
18
+ **We never set:**
19
+ - `CreatedById`, `CreatedDate`, `LastModifiedById`, `LastModifiedDate` — server-managed.
20
+ - `IsActive` — server-managed.
21
+ - `PermissionSetGroupId` — v1 skill only handles individual PermissionSets.
22
+
23
+ ## Uniqueness constraint
24
+
25
+ The database has a unique index on `(AssigneeId, PermissionSetId)` — a user cannot have the same PermissionSet assigned twice. Attempting a duplicate POST raises:
26
+
27
+ ```text
28
+ [
29
+ {
30
+ "message": "You can't add the same permission set to a user twice.",
31
+ "errorCode": "DUPLICATE_VALUE",
32
+ "fields": []
33
+ }
34
+ ]
35
+ ```
36
+
37
+ **This is different from `GroupMember`**, where duplicate POSTs silently succeed with the existing row's Id. The DUPLICATE_VALUE handler here is reachable and correct — it treats a duplicate as "already assigned" and increments `reused_count` for that pair.
38
+
39
+ ## Why we query, not upsert
40
+
41
+ `PermissionSetAssignment` has no external ID field. The Data API's upsert (`PATCH /sobjects/PermissionSetAssignment/<externalId>/<value>`) pattern is unavailable. Only:
42
+
43
+ - **POST** with `{AssigneeId, PermissionSetId}` — creates a new row (or raises DUPLICATE_VALUE).
44
+ - **DELETE** with `Id` — removes the assignment.
45
+
46
+ For an idempotent skill, we pre-detect existing pairs via SOQL, then POST only the missing ones — the same detect-then-POST pattern as `service-omni-queue-members-assign`.
47
+
48
+ ## SOQL patterns used
49
+
50
+ **Locate the agent users** (by the same pattern `service-omni-agent-users-create` produces):
51
+
52
+ ```sql
53
+ SELECT Id, Username
54
+ FROM User
55
+ WHERE Username IN ('agent1.<suffix>@example.com', ...)
56
+ ```text
57
+
58
+ **Locate target permission sets:**
59
+
60
+ ```sql
61
+ SELECT Id, Name, Label
62
+ FROM PermissionSet
63
+ WHERE Name IN ('Omni_Agent', ...)
64
+ ```
65
+
66
+ - We use `Name` (not `Label`) — Name is the API identifier; Label is localizable.
67
+ - **Not `DeveloperName`** — PermissionSet does not have `DeveloperName`; `Name` IS the DeveloperName-equivalent.
68
+
69
+ **Locate existing assignments:**
70
+
71
+ ```sql
72
+ SELECT Id, AssigneeId, PermissionSetId
73
+ FROM PermissionSetAssignment
74
+ WHERE AssigneeId IN ('005RZ...', '005RZ...', '005RZ...')
75
+ AND PermissionSetId IN ('0PSRZ...', '0PSRZ...')
76
+ ```text
77
+
78
+ - Scope to specific users + specific perm sets — avoids returning hundreds of unrelated rows on production-like orgs (real users have dozens of PSAs each).
79
+ - The cross-product size for our default case is 3 users × 1 perm set = 3 pairs; the query returns 0..3 rows.
80
+
81
+ ## POST payload
82
+
83
+ ```
84
+ POST /services/data/v66.0/sobjects/PermissionSetAssignment
85
+ {
86
+ "AssigneeId": "005RZ0000005abcBBB",
87
+ "PermissionSetId": "0PSRZ0000067UKT4A2"
88
+ }
89
+ ```text
90
+
91
+ - Success: HTTP 201 with `{"id":"0PaRZ00000ABCDE0AB","success":true,"errors":[]}`.
92
+ - Duplicate: HTTP 400 with `[{"errorCode":"DUPLICATE_VALUE","message":"You can't add the same permission set to a user twice.",...}]`.
93
+ - Missing user or perm set: HTTP 400 with `MALFORMED_ID` — indicates a skill bug (should have been caught by pre-detect).
94
+ - License-limit error: HTTP 400 with `LICENSE_LIMIT_EXCEEDED` — some perm sets require specific user licenses (e.g. Sales Cloud PSL requires Sales Cloud license).
95
+ - License-integrity error: HTTP 400 with `FIELD_INTEGRITY_EXCEPTION — The user license doesn't allow the permission: <name>` — the perm set grants a **system permission the assignee's user license does not permit**. This is why `Omni_Agent` must NOT include license-gated system permissions such as `OmniAssistiveActionUser` (an Agentforce/assistive-actions permission requiring a Service Agent/Agentforce entitlement). `Omni_Agent` instead grants agent capability via `servicePresenceStatusAccesses`, which a standard Salesforce/Service Cloud license permits. Verified live (Service Cloud profile, Salesforce license): `OmniAssistiveActionUser` → FIELD_INTEGRITY_EXCEPTION; presence-status access → assigns cleanly.
96
+
97
+ ## Self-heal deploy: presence statuses are generated from what exists on the org
98
+
99
+ A `PermissionSet` deploy fails (`no ServicePresenceStatus named <name> found`) if it references a presence status that does not exist. A VoiceCall-only org has `Available_Voice` (+ `Busy`) but not `Available_Case`; a Case-only org is the reverse. To stay target-agnostic, the self-heal step does **not** deploy a static `Omni_Agent` asset with a hardcoded `Available_Case`. Instead it queries `ServicePresenceStatus` on the org, intersects with the curated Omni demo set (`Available_Case`, `Available_Voice`, `Available_Messaging`, `Available_Chat`, `Available_Incident`, `Busy`), and generates the `servicePresenceStatusAccesses` block from only the statuses that actually exist — then deploys that generated permset from a temp SFDX project. As a result, `Omni_Agent` deploys cleanly on a Case org, a Voice org, or any mix, and the deploy never references a missing status. Run `service-omni-presence-status-deploy` first so at least one presence status exists.
100
+
101
+ ## sf api request rest emits a beta warning to stderr
102
+
103
+ `sf api request rest` prints `Warning: This command is currently in beta.` to stderr. To keep stdout pure JSON, the script captures stderr with the temp-file pattern:
104
+
105
+ ```bash
106
+ POST_STDERR="$(mktemp)"
107
+ POST_RESULT=$(sf api request rest ... 2>"$POST_STDERR" || true)
108
+ POST_SUCCESS=$(echo "$POST_RESULT" | jq -r '.success // false')
109
+ rm -f "$POST_STDERR"
110
+ ```
111
+
112
+ Never `2>&1` — the Warning line breaks jq parsing.
113
+
114
+ ## Permission required to POST PermissionSetAssignment
115
+
116
+ The user executing the sf CLI must have:
117
+
118
+ - `PermissionsAssignPermissionSets = true` (part of System Administrator profile; also included in the "Manage Permission Sets" standard permission)
119
+ - `ModifyAllData = true` (usually implied by the above)
120
+
121
+ If the CLI user lacks these, POST returns `INSUFFICIENT_ACCESS_OR_READONLY`. The skill's script translates this to "Re-authenticate as a System Administrator".
122
+
123
+ **Notable gotcha:** on some orgs, a user with `ModifyAllData=true` but WITHOUT `AssignPermissionSets=true` can query PSAs but not create them. This is a documented Salesforce behavior; our production CDOs typically bundle both perms on the SA profile, but scratch orgs sometimes ship the SA profile without `AssignPermissionSets`. If encountered, re-auth as a "true admin" or grant `AssignPermissionSets` manually.
124
+
125
+ ## Teardown hook (not implemented in v1)
126
+
127
+ A future `service-omni-permission-set-remove` skill would:
128
+
129
+ 1. Query `PermissionSetAssignment` where `AssigneeId IN <agent-users>` AND `PermissionSet.Name IN <target-names>`.
130
+ 2. DELETE each row via `/sobjects/PermissionSetAssignment/<id>`.
131
+ 3. Re-verify.
132
+
133
+ Not part of v1 coordinator's forward path — teardown is out of scope. A separate `teardown-coordinate` skill will handle it holistically.
134
+
135
+ ## Why not Apex?
136
+
137
+ `PermissionSetAssignment` insert is a simple 2-field row. Apex would let us bulk-insert all N rows in one transaction (~1 API round-trip instead of N), but the tradeoffs are:
138
+
139
+ - **N is small.** At 3 users × 1 perm set = 3 POSTs, the difference is a couple of seconds.
140
+ - **Simpler debugging.** REST POSTs return structured JSON; Apex debug logs require anchored regexes.
141
+ - **License-check surface.** POST returns `LICENSE_LIMIT_EXCEEDED` immediately for the offending pair without rolling back sibling successes. A bulk Apex insert would need more code to detect and report a partial success.
142
+
143
+ Assigning hundreds of perm sets at once would be a reason to revisit this and use Apex with `Database.insert(..., false)` for partial-success semantics.
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env bash
2
+ # verify-and-assign.sh - assign PermissionSets to N agent users via PermissionSetAssignment POSTs; idempotent (SOQL detects existing pairs), non-destructive. Args, output contract, exit codes: SKILL.md.
3
+
4
+ set -euo pipefail
5
+
6
+ if [ $# -lt 1 ]; then
7
+ echo '{"error":"Usage: bash verify-and-assign.sh <org-alias> [<count>] [<permission-set-names-csv>]"}' >&2
8
+ exit 1
9
+ fi
10
+
11
+ ORG="$1"
12
+ COUNT="${2:-3}"
13
+ PS_NAMES_CSV="${3:-Omni_Agent}"
14
+ API_VERSION="v66.0"
15
+
16
+ # Sanity: count 1..10
17
+ if ! [[ "$COUNT" =~ ^[0-9]+$ ]] || [ "$COUNT" -lt 1 ] || [ "$COUNT" -gt 10 ]; then
18
+ echo "{\"error\":\"Invalid count '$COUNT' - must be integer 1..10\"}" >&2
19
+ exit 1
20
+ fi
21
+
22
+ if ! sf org display --target-org "$ORG" --json >/dev/null 2>&1; then
23
+ echo "{\"error\":\"Org alias '$ORG' is not authenticated.\"}" >&2
24
+ exit 1
25
+ fi
26
+
27
+ # Parse CSV into a JSON array
28
+ PS_NAMES_JSON=$(echo "$PS_NAMES_CSV" | jq -R -c 'split(",") | map(gsub("^\\s+|\\s+$"; ""))')
29
+ PS_COUNT=$(echo "$PS_NAMES_JSON" | jq 'length')
30
+
31
+ # Each PermissionSet Name is interpolated into SOQL, so validate its DeveloperName shape before any
32
+ # sf call; an empty CSV is also rejected.
33
+ if [ "$PS_COUNT" -lt 1 ]; then
34
+ echo "{\"error\":\"No permission-set names supplied.\"}" >&2
35
+ exit 1
36
+ fi
37
+ while IFS= read -r ps_name; do
38
+ if ! [[ "$ps_name" =~ ^[A-Za-z][A-Za-z0-9_]{0,79}$ ]]; then
39
+ echo "{\"error\":\"Invalid permission-set name '$ps_name'. Each must start with a letter and contain only letters, digits, and underscores (max 80 chars).\"}" >&2
40
+ exit 1
41
+ fi
42
+ done < <(echo "$PS_NAMES_JSON" | jq -r '.[]')
43
+
44
+ # Helper: emit blocked JSON
45
+ emit_blocked() {
46
+ local msg="$1"
47
+ local click_path="${2:-}"
48
+ local suffix="${3:-}"
49
+
50
+ local manual_actions='[]'
51
+ if [ -n "$click_path" ]; then
52
+ manual_actions=$(jq -n --arg cp "$click_path" '[{id: "MANUAL_FIX", title: "Manual fix required", click_path: $cp}]')
53
+ fi
54
+
55
+ jq -n \
56
+ --arg msg "$msg" \
57
+ --arg suf "$suffix" \
58
+ --argjson count "$COUNT" \
59
+ --argjson ps_count "$PS_COUNT" \
60
+ --argjson ma "$manual_actions" \
61
+ '{
62
+ skill: "service-omni-permission-set-assign",
63
+ status: "blocked",
64
+ permission_sets: [],
65
+ org_suffix: (if $suf == "" then null else $suf end),
66
+ requested_count: $count,
67
+ expected_assignment_count: ($count * $ps_count),
68
+ before: null,
69
+ assigned_this_run: [],
70
+ assigned_count: 0,
71
+ reused_count: 0,
72
+ after: null,
73
+ manual_actions: $ma,
74
+ blocking_issue: $msg
75
+ }'
76
+ exit 1
77
+ }
78
+
79
+ # Helper: emit read-only plan preview JSON and exit 0 (PLAN_ONLY mode)
80
+ emit_plan() {
81
+ local status="$1"
82
+ local detail="$2"
83
+ jq -n \
84
+ --arg status "$status" \
85
+ --arg detail "$detail" \
86
+ '{
87
+ skill: "service-omni-permission-set-assign",
88
+ status: $status,
89
+ plan_mode: true,
90
+ plan_detail: $detail,
91
+ manual_actions: [],
92
+ blocking_issue: null
93
+ }'
94
+ exit 0
95
+ }
96
+
97
+ # Step 1 - Organization + safe_to_write
98
+ ORG_JSON=$(sf data query --target-org "$ORG" \
99
+ --query "SELECT Id, IsSandbox, TrialExpirationDate, OrganizationType FROM Organization LIMIT 1" \
100
+ --json 2>/dev/null)
101
+
102
+ ORG_ID=$(echo "$ORG_JSON" | jq -r '.result.records[0].Id')
103
+ IS_SANDBOX=$(echo "$ORG_JSON" | jq -r '.result.records[0].IsSandbox')
104
+ TRIAL_EXP=$(echo "$ORG_JSON" | jq -r '.result.records[0].TrialExpirationDate // "null"')
105
+ ORG_TYPE=$(echo "$ORG_JSON" | jq -r '.result.records[0].OrganizationType')
106
+
107
+ SAFE_TO_WRITE=false
108
+ if [ "$IS_SANDBOX" = "true" ] \
109
+ || [ "$TRIAL_EXP" != "null" ] \
110
+ || [ "$ORG_TYPE" = "Developer Edition" ] \
111
+ || [ "$ORG_TYPE" = "Base Edition" ]; then
112
+ SAFE_TO_WRITE=true
113
+ fi
114
+
115
+ SUFFIX=$(echo "$ORG_ID" | tr '[:upper:]' '[:lower:]' | tail -c 9 | head -c 8)
116
+
117
+ if [ "$SAFE_TO_WRITE" != "true" ]; then
118
+ emit_blocked "Refusing to assign permission sets on a real production customer org (IsSandbox=$IS_SANDBOX, TrialExpirationDate=$TRIAL_EXP, OrganizationType=$ORG_TYPE). Target a sandbox, trial CDO, or Developer Edition org." "" "$SUFFIX"
119
+ fi
120
+
121
+ # Step 2 - Locate target PermissionSets
122
+ # Build IN clause for PS names
123
+ PS_IN_CLAUSE=""
124
+ for name in $(echo "$PS_NAMES_JSON" | jq -r '.[]'); do
125
+ if [ -n "$PS_IN_CLAUSE" ]; then PS_IN_CLAUSE="$PS_IN_CLAUSE,"; fi
126
+ PS_IN_CLAUSE="$PS_IN_CLAUSE'$name'"
127
+ done
128
+
129
+ PS_JSON=$(sf data query --target-org "$ORG" \
130
+ --query "SELECT Id, Name, Label FROM PermissionSet WHERE Name IN ($PS_IN_CLAUSE)" \
131
+ --json 2>/dev/null)
132
+ FOUND_PS_COUNT=$(echo "$PS_JSON" | jq -r '.result.totalSize // 0')
133
+
134
+ # Self-heal: if an expected PermissionSet is missing but bundled as an asset, deploy it and re-query
135
+ # (so the chain works on a fresh org). safe_to_write was already enforced above.
136
+ if [ "$FOUND_PS_COUNT" -lt "$PS_COUNT" ]; then
137
+ FOUND_NAMES=$(echo "$PS_JSON" | jq -c '[.result.records[].Name]')
138
+ MISSING_NAMES=$(jq -n --argjson e "$PS_NAMES_JSON" --argjson f "$FOUND_NAMES" '$e - $f')
139
+ # PLAN_ONLY must be read-only: report the missing perm set(s) and stop before the deploy below.
140
+ if [ "${PLAN_ONLY:-}" = "1" ]; then
141
+ emit_plan "action_needed" "PermissionSet(s) $MISSING_NAMES not present. A --run would deploy the bundled asset (or require manual creation) before assigning; --plan makes no writes."
142
+ fi
143
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
144
+ ASSETS_DIR="$SCRIPT_DIR/../assets"
145
+ DEPLOYED_ANY=false
146
+ SELF_HEAL_ERR=""
147
+
148
+ # A PermissionSet deploy fails if it references a ServicePresenceStatus absent on the org, so
149
+ # rebuild the servicePresenceStatusAccesses block from the curated statuses that actually exist
150
+ # (none → deploy with no presence access, still a valid grant, rather than failing the chain).
151
+ build_omni_agent_permset() {
152
+ local out_file="$1"
153
+ local existing accesses="" s
154
+ existing=$(sf data query --target-org "$ORG" \
155
+ --query "SELECT DeveloperName FROM ServicePresenceStatus" --json 2>/dev/null \
156
+ | jq -r '[.result.records[].DeveloperName] // []' 2>/dev/null)
157
+ for s in Available_Case Available_Voice Available_Messaging Available_Chat Available_Incident Busy; do
158
+ if echo "$existing" | jq -e --arg s "$s" 'index($s)' >/dev/null 2>&1; then
159
+ accesses="${accesses} <servicePresenceStatusAccesses>
160
+ <enabled>true</enabled>
161
+ <servicePresenceStatus>${s}</servicePresenceStatus>
162
+ </servicePresenceStatusAccesses>
163
+ "
164
+ fi
165
+ done
166
+ cat > "$out_file" <<XML
167
+ <?xml version="1.0" encoding="UTF-8"?>
168
+ <PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
169
+ <description>Omni-Channel agent access for demo/test orgs: grants the service presence statuses that exist on the org so agents go online and receive routed work. No ViewAllData/ModifyAllData/ManageUsers or license-gated perms.</description>
170
+ <hasActivationRequired>false</hasActivationRequired>
171
+ <label>Omni Agent</label>
172
+ ${accesses}</PermissionSet>
173
+ XML
174
+ }
175
+
176
+ for miss in $(echo "$MISSING_NAMES" | jq -r '.[]'); do
177
+ if [ -f "$ASSETS_DIR/force-app/main/default/permissionsets/$miss.permissionset-meta.xml" ]; then
178
+ if [ "$miss" = "Omni_Agent" ]; then
179
+ # Deploy a dynamically-generated Omni_Agent (never references a non-existent presence status).
180
+ SH_WORK="$(mktemp -d)"
181
+ mkdir -p "$SH_WORK/force-app/main/default/permissionsets"
182
+ cat > "$SH_WORK/sfdx-project.json" <<'JSON'
183
+ { "packageDirectories": [{ "path": "force-app", "default": true }], "namespace": "", "sourceApiVersion": "66.0" }
184
+ JSON
185
+ build_omni_agent_permset "$SH_WORK/force-app/main/default/permissionsets/Omni_Agent.permissionset-meta.xml"
186
+ DEPLOY_OUT=$( cd "$SH_WORK" && sf project deploy start --target-org "$ORG" --metadata "PermissionSet:Omni_Agent" --json 2>/dev/null ) && DEPLOYED_ANY=true || \
187
+ SELF_HEAL_ERR="$SELF_HEAL_ERR $(echo "$DEPLOY_OUT" | jq -r '(.result.details.componentFailures // [] | map(.problem) | join("; ")) // .message // ""' 2>/dev/null)"
188
+ rm -rf "$SH_WORK"
189
+ else
190
+ DEPLOY_OUT=$( cd "$ASSETS_DIR" && sf project deploy start --target-org "$ORG" --metadata "PermissionSet:$miss" --json 2>/dev/null ) && DEPLOYED_ANY=true || \
191
+ SELF_HEAL_ERR="$SELF_HEAL_ERR $(echo "$DEPLOY_OUT" | jq -r '(.result.details.componentFailures // [] | map(.problem) | join("; ")) // .message // ""' 2>/dev/null)"
192
+ fi
193
+ fi
194
+ done
195
+ if [ "$DEPLOYED_ANY" = "true" ]; then
196
+ PS_JSON=$(sf data query --target-org "$ORG" \
197
+ --query "SELECT Id, Name, Label FROM PermissionSet WHERE Name IN ($PS_IN_CLAUSE)" \
198
+ --json 2>/dev/null)
199
+ FOUND_PS_COUNT=$(echo "$PS_JSON" | jq -r '.result.totalSize // 0')
200
+ fi
201
+ fi
202
+
203
+ # Block only if a permission set is STILL missing after the self-heal deploy attempt.
204
+ if [ "$FOUND_PS_COUNT" -lt "$PS_COUNT" ]; then
205
+ FOUND_NAMES=$(echo "$PS_JSON" | jq -c '[.result.records[].Name]')
206
+ MISSING_NAMES=$(jq -n --argjson expected "$PS_NAMES_JSON" --argjson found "$FOUND_NAMES" \
207
+ '$expected - $found')
208
+ emit_blocked \
209
+ "Expected PermissionSets $PS_NAMES_JSON; found $FOUND_NAMES. Missing: $MISSING_NAMES. Bundled perm sets deploy automatically; a non-bundled name must be created first via Setup or a package deploy.${SELF_HEAL_ERR:+ Self-heal deploy failed:${SELF_HEAL_ERR}}" \
210
+ "The bundled Omni_Agent grants whichever Omni service presence statuses exist on the org (Available_Case/Available_Voice/Available_Messaging/Busy). Run service-omni-presence-status-deploy first so at least one presence status exists. Otherwise: Setup → Permission Sets → New → Name: (missing DeveloperName) → configure access → Save, or deploy from your manifest: sf project deploy start -x <path-to-package.xml> -o $ORG" \
211
+ "$SUFFIX"
212
+ fi
213
+
214
+ # Build permission_sets array for the report
215
+ PERMISSION_SETS_JSON=$(echo "$PS_JSON" | jq -c '[.result.records[] | {developer_name: .Name, id: .Id, label: .Label}]')
216
+ PS_IDS_ARR=$(echo "$PS_JSON" | jq -c '[.result.records[].Id]')
217
+
218
+ # Build SOQL IN clause for PS IDs
219
+ PS_IDS_IN=""
220
+ for pid in $(echo "$PS_IDS_ARR" | jq -r '.[]'); do
221
+ if [ -n "$PS_IDS_IN" ]; then PS_IDS_IN="$PS_IDS_IN,"; fi
222
+ PS_IDS_IN="$PS_IDS_IN'$pid'"
223
+ done
224
+
225
+ # Step 3 - Locate expected agent users
226
+ EXPECTED_USERNAMES=()
227
+ for i in $(seq 1 "$COUNT"); do
228
+ EXPECTED_USERNAMES+=("agent${i}.${SUFFIX}@example.com")
229
+ done
230
+
231
+ USER_IN_CLAUSE=""
232
+ for un in "${EXPECTED_USERNAMES[@]}"; do
233
+ if [ -n "$USER_IN_CLAUSE" ]; then USER_IN_CLAUSE="$USER_IN_CLAUSE,"; fi
234
+ USER_IN_CLAUSE="$USER_IN_CLAUSE'$un'"
235
+ done
236
+
237
+ # Only active users are valid assignees; filter IsActive=true so an inactive occupant can't satisfy the count.
238
+ U_JSON=$(sf data query --target-org "$ORG" \
239
+ --query "SELECT Id, Username FROM User WHERE Username IN ($USER_IN_CLAUSE) AND IsActive=true" \
240
+ --json 2>/dev/null)
241
+
242
+ U_COUNT=$(echo "$U_JSON" | jq -r '.result.totalSize // 0')
243
+ if [ "$U_COUNT" -lt "$COUNT" ]; then
244
+ FOUND=$(echo "$U_JSON" | jq -c '[.result.records[].Username]')
245
+ emit_blocked \
246
+ "Expected $COUNT ACTIVE agent users matching pattern 'agent{1..$COUNT}.$SUFFIX@example.com'; found $U_COUNT active ($FOUND). Run service-omni-agent-users-create with count=$COUNT first (and reactivate any deactivated agent users)." \
247
+ "" "$SUFFIX"
248
+ fi
249
+
250
+ USERS_JSON=$(echo "$U_JSON" | jq -c '[.result.records[] | {id: .Id, username: .Username}]')
251
+ USER_IDS_ARR=$(echo "$USERS_JSON" | jq -c '[.[].id]')
252
+
253
+ USER_IDS_IN=""
254
+ for uid in $(echo "$USER_IDS_ARR" | jq -r '.[]'); do
255
+ if [ -n "$USER_IDS_IN" ]; then USER_IDS_IN="$USER_IDS_IN,"; fi
256
+ USER_IDS_IN="$USER_IDS_IN'$uid'"
257
+ done
258
+
259
+ # Step 4 - Query existing PermissionSetAssignments for these pairs
260
+ PSA_JSON=$(sf data query --target-org "$ORG" \
261
+ --query "SELECT Id, AssigneeId, PermissionSetId FROM PermissionSetAssignment WHERE AssigneeId IN ($USER_IDS_IN) AND PermissionSetId IN ($PS_IDS_IN)" \
262
+ --json 2>/dev/null)
263
+
264
+ # Build the "existing_pairs" set as JSON array of {user_id, permission_set_id}
265
+ EXISTING_PAIRS=$(echo "$PSA_JSON" | jq -c '[.result.records[] | {user_id: .AssigneeId, permission_set_id: .PermissionSetId}]')
266
+ EXISTING_COUNT=$(echo "$EXISTING_PAIRS" | jq 'length')
267
+
268
+ # Build the "expected_pairs" set as cross-product of users x perm-sets
269
+ EXPECTED_PAIRS=$(jq -n --argjson users "$USERS_JSON" --argjson pss "$PERMISSION_SETS_JSON" \
270
+ '[$users[] as $u | $pss[] as $p | {user_id: $u.id, username: $u.username, permission_set_id: $p.id, permission_set_developer_name: $p.developer_name}]')
271
+
272
+ EXPECTED_COUNT=$((COUNT * PS_COUNT))
273
+
274
+ # missing = expected - existing (compared on user_id + permission_set_id keys)
275
+ MISSING_PAIRS=$(jq -n --argjson expected "$EXPECTED_PAIRS" --argjson existing "$EXISTING_PAIRS" \
276
+ '[$expected[] | . as $e | select($existing | map(select(.user_id == $e.user_id and .permission_set_id == $e.permission_set_id)) | length == 0)]')
277
+ MISSING_COUNT=$(echo "$MISSING_PAIRS" | jq 'length')
278
+
279
+ # PLAN_ONLY: all detection above is read-only. Report what a --run would do and stop before any write.
280
+ if [ "${PLAN_ONLY:-}" = "1" ]; then
281
+ if [ "$MISSING_COUNT" -eq 0 ]; then
282
+ emit_plan "reused" "All $EXPECTED_COUNT (user x permission-set) assignment(s) already present."
283
+ else
284
+ emit_plan "action_needed" "Would assign $MISSING_COUNT missing of $EXPECTED_COUNT expected (user x permission-set) pair(s)."
285
+ fi
286
+ fi
287
+
288
+ # Step 5 - POST PermissionSetAssignment for each missing pair
289
+ ASSIGNED_THIS_RUN='[]'
290
+ ASSIGNED_COUNT=0
291
+ PARTIAL_ERRORS=()
292
+
293
+ if [ "$MISSING_COUNT" -gt 0 ]; then
294
+ for row in $(echo "$MISSING_PAIRS" | jq -c '.[]'); do
295
+ USER_ID=$(echo "$row" | jq -r '.user_id')
296
+ USERNAME=$(echo "$row" | jq -r '.username')
297
+ PS_ID=$(echo "$row" | jq -r '.permission_set_id')
298
+ PS_DEV_NAME=$(echo "$row" | jq -r '.permission_set_developer_name')
299
+
300
+ # Redirect the sf beta warning off stderr so stdout is pure JSON for jq.
301
+ POST_STDERR="$(mktemp)"
302
+ POST_RESULT=$(sf api request rest --target-org "$ORG" \
303
+ "/services/data/$API_VERSION/sobjects/PermissionSetAssignment" \
304
+ --method POST \
305
+ --body "{\"AssigneeId\":\"$USER_ID\",\"PermissionSetId\":\"$PS_ID\"}" 2>"$POST_STDERR" || true)
306
+
307
+ POST_SUCCESS=$(echo "$POST_RESULT" | jq -r '.success // false' 2>/dev/null || echo "false")
308
+
309
+ if [ "$POST_SUCCESS" = "true" ]; then
310
+ PSA_ID=$(echo "$POST_RESULT" | jq -r '.id')
311
+ ASSIGNED_THIS_RUN=$(echo "$ASSIGNED_THIS_RUN" | jq -c \
312
+ --arg uid "$USER_ID" --arg un "$USERNAME" \
313
+ --arg psdn "$PS_DEV_NAME" --arg psaid "$PSA_ID" \
314
+ '. + [{user_id: $uid, username: $un, permission_set_developer_name: $psdn, assignment_id: $psaid}]')
315
+ ASSIGNED_COUNT=$((ASSIGNED_COUNT + 1))
316
+ else
317
+ ERR_CODE=$(echo "$POST_RESULT" | jq -r 'if type == "array" then .[0].errorCode else .errorCode // "UNKNOWN" end' 2>/dev/null || echo "UNKNOWN")
318
+ ERR_MSG=$(echo "$POST_RESULT" | jq -r 'if type == "array" then .[0].message else .message // "Unknown POST error" end' 2>/dev/null || echo "Unknown POST error")
319
+
320
+ if [ "$ERR_CODE" = "UNKNOWN" ] && [ -z "$POST_RESULT" ]; then
321
+ ERR_MSG=$(grep -v '^Warning:' "$POST_STDERR" | head -1 || echo "sf CLI produced no output")
322
+ fi
323
+
324
+ if [ "$ERR_CODE" = "DUPLICATE_VALUE" ]; then
325
+ # Concurrent race (assigned between detect and POST); treat as reused, don't increment.
326
+ :
327
+ else
328
+ PARTIAL_ERRORS+=("$USERNAME ($USER_ID) → $PS_DEV_NAME: $ERR_CODE - $ERR_MSG")
329
+ fi
330
+ fi
331
+ rm -f "$POST_STDERR"
332
+ done
333
+ fi
334
+
335
+ # Step 6 - Re-query final assignment state
336
+ AFTER_PSA_JSON=$(sf data query --target-org "$ORG" \
337
+ --query "SELECT AssigneeId, PermissionSetId FROM PermissionSetAssignment WHERE AssigneeId IN ($USER_IDS_IN) AND PermissionSetId IN ($PS_IDS_IN)" \
338
+ --json 2>/dev/null)
339
+
340
+ AFTER_PAIRS=$(echo "$AFTER_PSA_JSON" | jq -c '[.result.records[] | {user_id: .AssigneeId, permission_set_id: .PermissionSetId}]')
341
+ AFTER_COUNT=$(echo "$AFTER_PAIRS" | jq 'length')
342
+
343
+ # Step 7 - Compute status
344
+ REUSED_COUNT=$EXISTING_COUNT
345
+
346
+ if [ ${#PARTIAL_ERRORS[@]} -gt 0 ] || [ "$AFTER_COUNT" -lt "$EXPECTED_COUNT" ]; then
347
+ STATUS="partial"
348
+ BLOCKING_ISSUE="Partial assign: after re-query, $AFTER_COUNT of $EXPECTED_COUNT expected pairs are present. Failures: ${PARTIAL_ERRORS[*]:-"(none; count mismatch only)"}"
349
+ elif [ "$ASSIGNED_COUNT" -eq 0 ]; then
350
+ STATUS="reused"
351
+ BLOCKING_ISSUE=""
352
+ else
353
+ STATUS="assigned"
354
+ BLOCKING_ISSUE=""
355
+ fi
356
+
357
+ # Step 8 - Emit report
358
+ BLOCKING_JQ_ARG='null'
359
+ if [ -n "$BLOCKING_ISSUE" ]; then
360
+ BLOCKING_JQ_ARG=$(jq -n --arg s "$BLOCKING_ISSUE" '$s')
361
+ fi
362
+
363
+ jq -n \
364
+ --arg status "$STATUS" \
365
+ --argjson pss "$PERMISSION_SETS_JSON" \
366
+ --arg suf "$SUFFIX" \
367
+ --argjson count "$COUNT" \
368
+ --argjson expected "$EXPECTED_COUNT" \
369
+ --argjson before_pairs "$EXISTING_PAIRS" \
370
+ --argjson before_count "$EXISTING_COUNT" \
371
+ --argjson assigned "$ASSIGNED_THIS_RUN" \
372
+ --argjson assigned_count "$ASSIGNED_COUNT" \
373
+ --argjson reused_count "$REUSED_COUNT" \
374
+ --argjson after_pairs "$AFTER_PAIRS" \
375
+ --argjson after_count "$AFTER_COUNT" \
376
+ --argjson blocking "$BLOCKING_JQ_ARG" \
377
+ '{
378
+ skill: "service-omni-permission-set-assign",
379
+ status: $status,
380
+ permission_sets: $pss,
381
+ org_suffix: $suf,
382
+ requested_count: $count,
383
+ expected_assignment_count: $expected,
384
+ before: {
385
+ existing_assignment_pairs: $before_pairs,
386
+ existing_count: $before_count
387
+ },
388
+ assigned_this_run: $assigned,
389
+ assigned_count: $assigned_count,
390
+ reused_count: $reused_count,
391
+ after: {
392
+ assignment_pairs: $after_pairs,
393
+ assignment_count: $after_count
394
+ },
395
+ manual_actions: [],
396
+ blocking_issue: $blocking
397
+ }'