Approval-group management lands in the Config Agent
The V3 CPQ config agent can now create, edit, and delete approval groups and their members — and a new read-only sub-agent resolves "add Jane" to an exact userId before anything gets written.
askUserManagementAgent) and a new lifecycle section. The agent now treats group writes as LIVE, not drafts — distinct from rules, products, and flow.
PENDING approvals, OOO replacement rules, and smart-approval precedents all block; an unreadable spec also blocks until force=true.
1. Why this exists
Before this PR, the config agent could manage approval rules (the routing edges in the workflowSpec) but not the groups those rules route to. To set up approvers you still had to leave chat and open the V3 Admin UI. This PR closes the loop: chat — or a config card — can now stand up a group, set its members, edit its threshold, and delete it.
Two facts about the domain shape every design decision in the diff:
ApprovalGroup is a Prisma row, not a node in workflowSpec. There is no draft layer for it — writes apply live immediately. This breaks symmetry with every other thing the agent edits.
userIds, not namesuserId — with explicit handling for ambiguity ("which Jane?") and near-miss typos. This is why a dedicated sub-agent exists.
2. The four-agent topology
Step through a typical "add Jane and the VP to Finance Leadership" turn. The User Management agent is read-only; only the approvalRule agent writes.
2. Persistence model: rules vs groups
This is the single most important mental shift in the PR. Every other thing the agent touches saves as a draft. Groups don't.
Writes go into a draft workflowSpec (or product/flow spec). The user explicitly publishes drafts through the V3 Admin UI. Discardable.
Edit flow: change → preview draft → publish.
Writes hit the ApprovalGroup Prisma row immediately. There is no draft. The orchestrator has to gate every write behind explicit user approval (or a config-card submit).
Edit flow: change → applied now.
The orchestrator prompt is updated to state this exception explicitly, and every group tool's description ends with "Writes LIVE." so the LLM can't conflate it with the draft tools.
3. Guarded-hybrid persistence
Groups are written via one of two paths, picked at runtime per operation. This is the trickiest piece of the diff and the comment in approvalGroupTools.ts is worth quoting:
ApprovalGroupController when the org has a V2 APPROVAL_RULE workflow config (so the V2 workflow-graph node stays in sync), else they fall back to the repository. Updates always go straight to the repository — they touch only the ApprovalGroup row, and routing through the controller would re-read getApprovalGroupChainOrder and throw for any group without a graph node, after the row write already committed.
| Operation | With V2 workflow config | Pure V3 org | Why |
|---|---|---|---|
createApprovalGroup |
Controller | Repository | Keep V2 graph node + row in sync. |
updateApprovalGroupConfigadd/remove/setGroupMembers |
Repository (always) | Members/config don't change the V2 graph node. Controller would post-throw. | |
deleteApprovalGroup |
Controller | Repository | Soft-delete + V2 graph cleanup. |
4. The User Management sub-agent
A small read-only agent that exists for one job: turn a name into a userId. It owns three tools — listOrgUsers, resolveUserByName, getUser — and has no write access of any kind.
The interesting piece is the verdict system in userMatching.ts. resolveUserByName doesn't return a single guess — it returns a ranked candidate list and a verdict that tells the orchestrator how to behave next:
Matching subtleties worth knowing
The matcher classifies each candidate as exact, strong, or fuzzy. Two non-obvious decisions are called out in comments and in the tests:
- "strong" is restricted to a full name-token match or a name-prefix. Earlier versions used arbitrary substrings, which made
"an"strong-match "Megan" andnq.startsWith(nn)let "Sandra" strong-match a user literally named "San". - The verdict is computed before truncation. If 8 people share a name and the display limit is 2, the result is still
ambiguous_exact— not a misleadingunique_exact.
// userMatching.spec.ts
it('keeps the ambiguous verdict even when the display limit truncates', () => {
const dir = Array.from({ length: 8 }, (_, i) => u(String(i), 'Same Name'));
const r = resolveUsersByName('Same Name', dir, 2);
expect(r.resolution).toBe('ambiguous_exact');
expect(r.candidates).toHaveLength(2);
});
One small but useful optimization: the directory load (DB findMany + a paginated Stytch email fetch) is memoized per sub-agent invocation so a multi-name turn doesn't re-fetch on every call. A failure isn't cached — a transient DB hiccup won't poison the rest of the turn.
5. The config card path
The chat-driven path resolves names tool-by-tool. But the most common case ("set up Finance Leadership") opens a card. The card is a stateless input collector — it never writes — and the round-trip is carefully designed.
getApprovalGroupDetails first and passes the returned prefill object straight into openApprovalGroupModal. The prefill includes groupId + current members, so the card opens in edit mode showing the right state.
[Modal:approval_group] message with name, settings, and member list. Orchestrator delegates to askApprovalRuleAgent which calls saveApprovalGroup — one call writes config + members together.
saveApprovalGroup reads the existing group and re-attaches any MANAGER-level assignees server-side. The v1 card only manages USER members; missing managers in the payload would otherwise silently drop them.
getApprovalGroupDetails matters: skipping it on an edit makes the card open as a blank "New Approval Group" that silently omits the existing members. The orchestrator prompt now states this in capital letters — it's an easy failure mode to land in.
Card-side guards
The modal also enforces a small set of invariants client-side, mirrored by the server:
// ApprovalGroupModal.tsx — submit button
submitDisabled={
!name.trim() ||
(members.length > 0 && requiredApprovals > members.length)
}
// Sends the trimmed value (incl. '') so clearing a field actually clears
// it server-side — `|| undefined` would make a cleared field a no-op.
description: description.trim(),
6. The seven group tools
All seven live in approvalGroupTools.ts and are exposed on the approvalRule sub-agent. Active users only — newly added USER assignees are validated against the directory (must exist AND be ACTIVE) before the write.
| Tool | Use case | Notes |
|---|---|---|
saveApprovalGroup | Card-submit path (create OR update) | One call. Re-attaches existing MANAGER members on update. |
createApprovalGroup | Conversational create | No name-based reuse — always creates new. |
updateApprovalGroupConfig | Non-member edits | Empty string clears description / slackChannelId. |
addGroupMembers | Append members | Dedupes against existing + within the batch. |
removeGroupMembers | Remove specific members | Other members preserved. |
setGroupMembers | Conversational "set members to X, Y, Z" | Full replace. |
deleteApprovalGroup | Soft-delete | Reference-guarded; force=true override. |
All member writers share a single writeMembers helper that re-validates the threshold — requiredApprovals ≤ memberCount — on every change, so you can't end up with a group of 2 that requires 3 approvals.
7. The delete guard, in detail
Soft-delete is the riskiest operation here, and findGroupReferences in approvalGroupTools.ts is built to be paranoid. It scans four reference sources in parallel:
routes[].destination.groupId matches blocks. Deduped across both specs.
PendingApprovalAction rows with status PENDING — actual in-flight deal approvals routed to this group.
ApprovalReplacementRule rows listing the group in approvalGroupIds. Out-of-office rerouting depends on it.
SmartApprovalPrecedent rows whose consentingGroupId is this group.
Fail-closed on scan errors
The really careful bit: if loading either the draft OR published workflowSpec fails (not "is empty" — actually errors), the guard sets a scanError and refuses to delete even if no references were found in the sources that did load. The error message is explicit: "Could not fully verify what references this group… Refusing to delete without explicit confirmation."
And — a thoughtful detail — a genuine "no spec exists yet" error from loadForPreview is distinguished from a real failure via isAbsentSpecError, so fresh orgs aren't blocked from deleting groups:
function isAbsentSpecError(reason: unknown): boolean {
const msg = reason instanceof Error ? reason.message : String(reason);
return msg.includes('No spec found');
}
8. What it doesn't change
- The draft/publish workflow for products, pricing, approval rules, and flow specs is untouched.
- No new tRPC routes — the agent uses existing Prisma repositories and the V2
ApprovalGroupController. - The V3 Admin UI's own approval group management is not affected. The card opens inside the ConfigAgent chat surface only.
- Adding new manager-level approvers via the card — explicitly punted to a fast-follow. Existing manager assignees on a group are preserved but you can't add a new one through the v1 picker.
- The
Conversation.pendingApprovalGroupIdsfield is removed fromtypes.ts. It existed to soft-delete orphan groups when a draft was discarded — irrelevant now that groups write live and there is no draft to discard.
9. Risks & open questions
logSubAgent({ agent: 'userManagement' }) and the approvalRule agent for unexpected group-write call patterns.
approvalGroupLogic.spec.ts and userMatching.spec.ts exercise the assignee normalizer, the dedupe key, the reference scanner over draft+published, and every verdict branch including the truncation-vs-ambiguity edge case. IO paths (controller-vs-repo selection, Stytch enrichment, delete guard end-to-end) are not covered by new tests — would expect those to land alongside or shortly after merge.