Approval groups + a User Management sub-agent for the V3 Config Agent

The V3 CPQ config agent can now create, edit, and delete approval groups (and their members) — live, name-resolved through a new read-only sub-agent, and guarded against orphaning rules or pending approvals.

Author @yijunz166 PR dealops#5965 Branch yijunz/approval-group-management Files 15 Δ +2136 / −17 Area Dealops 3 · ConfigAgent State Open

What it adds

Approval-group management in the config agent: create, edit config, add/remove members, soft-delete — plus a config card (ApprovalGroupModal) for structured input.

New sub-agent

A read-only User Management agent resolves "Jane" or "the VP" into an exact userId, with fuzzy matching and explicit ambiguity / no-match verdicts. The orchestrator never guesses.

Key invariant

Approval groups are relational, not drafted. Group writes apply live; rule (workflowSpec edge) writes still save as drafts. The system prompts now repeat this contract loudly.

Safety

Delete is reference-guarded and fail-closed: scans draft + published workflowSpec edges, V2 workflow-graph edges, live PENDING approvals, OOO replacement rules, and smart-approval precedents.

Orchestrator
User Mgmt sub-agent (read-only)
Approval Rule sub-agent (writes)
Persistence (DB / V2 controller)
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 approval groups those rules pointed at. To add Jane to "Finance Leadership", a user had to leave the chat and open the V3 Admin UI.

Problem

Groups were managed out-of-band. Worse: the orchestrator had no safe way to turn a name ("add Jane") into a userId — guessing risks adding the wrong person to a real approval chain.

Fix

Group CRUD tools on the approvalRule sub-agent, a config card for structured edits, and a dedicated read-only sub-agent whose only job is name → userId resolution with explicit verdicts.

Follow-up

v1 card manages USER members; manager-level approvers are preserved on save but added/edited elsewhere. Adding manager-level approvers from the card is a fast-follow.

2. The two persistence models, side by side

The most important conceptual rule in this PR — repeated in every sub-agent and orchestrator system prompt — is that rules and groups persist differently. Keep them straight when reviewing.

Approval RULES (workflowSpec edges)

Drafted. Writes go to the draft workflowSpec. The user explicitly publishes via the V3 Admin UI. Multiple rule changes can be batched. Drafts can be discarded.

Touched by: createApprovalRuleTools, bulkWriteApprovalRules.

Approval GROUPS (relational rows)

Live. Writes hit prisma.approvalGroup immediately. No draft, no publish step. The orchestrator must gate every mutation behind explicit user approval (action_plan or card submit) before delegating.

Touched by: createApprovalGroupTools in approvalGroupTools.ts.

Cleanup signal: the old pendingApprovalGroupIds field on Conversation is removed. It existed so a discarded draft could soft-delete groups created during the session — but since groups are now permanently relational and the orchestrator gates each create, that draft-lifecycle hack is no longer needed.

3. The end-to-end flow: "add Jane and the VP of Sales to Finance Leadership"

This is the new lifecycle: the orchestrator resolves names before delegating writes, optionally renders a prefilled card, and the approval-rule sub-agent commits live.

4. The User Management sub-agent

The simplest piece, but it's the one that prevents a whole class of bugs. It owns three read-only tools and returns a verdict the orchestrator is required to act on.

VerdictMeaningRequired orchestrator behavior
unique_exactOne normalized-exact name match.Proceed with that userId.
ambiguous_exact2+ people share the exact name.Ask the user which one via askStructuredQuestion, listing role/manager.
fuzzy_candidatesNo exact match, only similar names.Confirm with the user before using a suggestion.
no_matchNothing crossed the 0.6 similarity floor.Tell the user; offer to list users.

Matching, by the numbers

The pure logic in tools/userMatching.ts normalizes names (lowercase, strip accents/punct, collapse whitespace) and classifies each candidate as exact, strong, or fuzzy. Two subtleties from the test suite worth flagging:

Strong ≠ any substring

A "strong" match is a full name token ("Smith" matches "Jane Smith") or a name prefix. Mid-string substrings don't count — "an" no longer strong-matches "Megan Brown".

Verdict before truncation

The verdict is computed over all matches, then candidates are sliced to limit. A directory of 8 same-name users with limit=2 still returns ambiguous_exact, not a misleading "unique".

Directory loading detail (click to expand)

The directory mirrors the tRPC user.list path: UserRepository.getUsersForOrganization for DB rows, then getStytchMemberEmailsInBulk for email enrichment (emails aren't a DB column). A Stytch failure degrades to no-email rather than failing the read.

The directory is memoized for the lifetime of a single sub-agent invocation, since one turn often resolves several names. Rejections aren't cached — a transient DB error doesn't poison the rest of the turn.

5. The Approval Group tools

approvalGroupTools.ts is the bulk of the diff (+863 lines). It exposes seven tools to the approvalRule sub-agent, each writing live.

ToolPurposeNotes
saveApprovalGroupOne-shot create OR update from a card submit.Preserves manager-level approvers on edit. The prompt tells the sub-agent to use this for every [Modal:approval_group] submit.
createApprovalGroupConversational create.Refuses on duplicate name (case-insensitive). Default requiredApprovals=1.
updateApprovalGroupConfigEdit non-member config.Members preserved. Empty string clears description / slackChannelId.
addGroupMembers / removeGroupMembersDelta edits.Dedupe against existing + within the incoming batch.
setGroupMembersReplace the entire member list.For "set members to X, Y, Z".
deleteApprovalGroupSoft-delete.Reference-guarded, fail-closed. See §6.

The guarded-hybrid persistence pattern

This is the most subtle part of the file. Group rows live in prisma.approvalGroup, but orgs that still have a V2 workflow config also have a workflow-graph node mirroring each group. The PR routes through the V2 controller only when needed, and only for the ops that actually touch the graph:

OperationV2 config presentV2 config absent
createApprovalGroupController (keeps graph in sync)ApprovalGroupRepository (row only)
update (config + members)ApprovalGroupRepository — always.
deleteApprovalGroupControllerApprovalGroupRepository

Why updates skip the controller: a code comment spells out the bug they're avoiding. Member/config edits never change the V2 graph node (it stores only groupId + chainOrder), and routing through the controller would re-read getApprovalGroupChainOrder and throw for any group lacking a graph node — after the row write already committed. So updates always go straight to the repository.

Validation invariants enforced by every write path

6. The delete guard — reference-scanned and fail-closed

Deleting a group that anything still routes to would orphan rules and break live approvals. findGroupReferences scans five sources before allowing a delete:

V3 workflowSpec edges

Both draft and published, via loadForPreview + loadV3WorkflowSpec. Deduped by edge id.

V2 workflow-graph edges

Count of nodes in the V2 APPROVAL_RULE workflow config whose destination === groupId.

Live PENDING approvals

pendingApprovalAction rows in PENDING state on this group.

OOO replacement rules

approvalReplacementRule rows whose approvalGroupIds array contains this group.

Smart-approval precedents

smartApprovalPrecedent rows whose consentingGroupId is this group.

Fail-closed on scan failure. If a spec load fails (anything except "No spec found"), scanError is set and the guard refuses to delete without force=true, even if no references were found in the partial scan. A fresh org with no spec is treated as empty (not a failure), so the guard doesn't needlessly block deletes for new orgs.

// approvalGroupTools.ts — delete decision
const mustConfirm = referenced || Boolean(refs.scanError);
if (mustConfirm && !force) {
  const message = refs.scanError
    ? `Could not fully verify what references this group (failed to load: ${refs.scanError}). Refusing to delete without explicit confirmation...`
    : `This group is still referenced — approval rules: ${refs.rules.length}, V2 workflow edges: ${refs.v2Edges}, live PENDING approvals: ${refs.pendingApprovals}, ...`;
  return fail(message, { blocked: true, references: refs });
}

7. The config card

ApprovalGroupModal.tsx follows the existing ConfigAgent modal pattern: stateless input collector, never writes. On submit it forwards the payload back to the chat as a [Modal:approval_group] message; the orchestrator delegates the actual write to the approvalRule sub-agent's saveApprovalGroup.

Edit mode

Opened via getApprovalGroupDetails(groupId) → its prefill object is passed verbatim to openApprovalGroupModal. The card opens populated with current name, settings, and member chips.

Create mode

Opened with no groupId. Optionally prefill with a name + any members the orchestrator already resolved via the User Management agent.

Client-side guards in the card

8. Orchestrator prompt changes

The orchestrator system prompt grows a new "APPROVAL GROUP LIFECYCLE" section. The non-obvious nuance:

To edit an existing group, you MUST fetch its details first. Skipping getApprovalGroupDetails makes the card open as a blank "New Approval Group" that silently omits existing members. The prompt says this explicitly: "NEVER open the card for an existing group without fetching its details first."

Also notable: the prompt corrects an older assumption baked into the step-4 approval ingestion logic. Previously it said createApprovalGroup "reuses existing groups by name automatically." It no longer does — there is no name-based reuse. The orchestrator must check listApprovalGroups first and only create if absent.

9. What it doesn't change

10. Risks & open questions

Live writes are unforgiving. Every group mutation hits production immediately. The safety net is (a) the orchestrator gating each mutation behind the action_plan flow or a card submit, and (b) the delete reference guard. Reviewers should pay particular attention to whether any code path could call addGroupMembers / setGroupMembers / deleteApprovalGroup without the user having actually agreed to that specific change.
Hybrid persistence drift. If an org's V2 workflow config is created or deleted between a create and a later delete, the two operations could route through different code paths. Both paths target the same row, so the worst case is a stale V2 graph node — but worth verifying there's no org in that state in production today.
Test coverage is solid for pure logic. approvalGroupLogic.spec.ts and userMatching.spec.ts exercise the dedupe, assignee mapping, reference-edge collection, and the four resolution verdicts (including the substring-vs-strong distinction and the post-truncation verdict invariant). The IO-heavy bits in approvalGroupTools.ts are not unit-tested in this PR — integration coverage will need to land separately.