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.

PRdealops#5963 Author@yijunz166 Branchyijunz/agent-approval-group-mgmt Files15 Δ+2051 / −17 AreaDealops 3 · ConfigAgent StatusOpen

What it adds
A new approvalGroup tool surface on the approvalRule sub-agent (7 tools), a read-only User Management sub-agent for name resolution, and an ApprovalGroupModal config card with a searchable user picker.
What it changes
The orchestrator gains a fourth specialist (askUserManagementAgent) and a new lifecycle section. The agent now treats group writes as LIVE, not drafts — distinct from rules, products, and flow.
What it preserves
The draft/publish model for everything else. Existing manager-level approvers on a group are never silently dropped — the v1 card manages only USER members but the server re-attaches MANAGER members on save.
Safety posture
Deletes are reference-guarded and fail-closed: rules, live PENDING approvals, OOO replacement rules, and smart-approval precedents all block; an unreadable spec also blocks until force=true.
Orchestrator
User Mgmt sub-agent (read-only)
ApprovalRule sub-agent (writes)
LIVE persistence (DB)
Config card (client)

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:

Fact 1 · Groups are relational, not in a spec
An 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.
Fact 2 · Members are userIds, not names
The agent must never guess. "Add Jane" needs to become an exact userId — 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.

Rules, products, flow · DRAFT

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.

Groups · LIVE

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:

Create / delete route through 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.
OperationWith V2 workflow configPure V3 orgWhy
createApprovalGroup Controller Repository Keep V2 graph node + row in sync.
updateApprovalGroupConfig
add/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:

unique_exact
One confident normalized-exact match. Orchestrator proceeds.
ambiguous_exact
Two+ people share the name. Must ask the user which one (with role / manager to disambiguate).
fuzzy_candidates
No exact match, only similar names. Confirm a suggestion with the user before using it.
no_match
Nothing crossed the similarity floor of 0.6. Tell the user; offer to list users.

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:

// 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.

Open
For an EDIT, orchestrator calls 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.
Submit
The card emits a [Modal:approval_group] message with name, settings, and member list. Orchestrator delegates to askApprovalRuleAgent which calls saveApprovalGroup — one call writes config + members together.
Preserve
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.
Why 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.

ToolUse caseNotes
saveApprovalGroupCard-submit path (create OR update)One call. Re-attaches existing MANAGER members on update.
createApprovalGroupConversational createNo name-based reuse — always creates new.
updateApprovalGroupConfigNon-member editsEmpty string clears description / slackChannelId.
addGroupMembersAppend membersDedupes against existing + within the batch.
removeGroupMembersRemove specific membersOther members preserved.
setGroupMembersConversational "set members to X, Y, Z"Full replace.
deleteApprovalGroupSoft-deleteReference-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:

workflowSpec edges
Both draft AND published. Any edge whose routes[].destination.groupId matches blocks. Deduped across both specs.
Live PENDING approvals
PendingApprovalAction rows with status PENDING — actual in-flight deal approvals routed to this group.
OOO replacement rules
ApprovalReplacementRule rows listing the group in approvalGroupIds. Out-of-office rerouting depends on it.
Smart-approval precedents
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

9. Risks & open questions

LIVE writes from chat are inherently riskier. The orchestrator's action-plan gating and the card's submit-as-approval pattern carry the safety load. A regression in the planner that skips approval would now have direct production impact on the approval routing graph. Worth watching telemetry from logSubAgent({ agent: 'userManagement' }) and the approvalRule agent for unexpected group-write call patterns.
Deactivated user re-activation race. The active-user check happens at write time, but a user deactivated after being added stays as a stale group member. That's pre-existing behavior, not introduced here — but the agent now makes it easier to accumulate.
Test coverage is reasonable for new pure logic. 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.