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.
Approval-group management in the config agent: create, edit config, add/remove members, soft-delete — plus a config card (ApprovalGroupModal) for structured input.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Verdict | Meaning | Required orchestrator behavior |
|---|---|---|
unique_exact | One normalized-exact name match. | Proceed with that userId. |
ambiguous_exact | 2+ people share the exact name. | Ask the user which one via askStructuredQuestion, listing role/manager. |
fuzzy_candidates | No exact match, only similar names. | Confirm with the user before using a suggestion. |
no_match | Nothing 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:
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".
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.
| Tool | Purpose | Notes |
|---|---|---|
saveApprovalGroup | One-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. |
createApprovalGroup | Conversational create. | Refuses on duplicate name (case-insensitive). Default requiredApprovals=1. |
updateApprovalGroupConfig | Edit non-member config. | Members preserved. Empty string clears description / slackChannelId. |
addGroupMembers / removeGroupMembers | Delta edits. | Dedupe against existing + within the incoming batch. |
setGroupMembers | Replace the entire member list. | For "set members to X, Y, Z". |
deleteApprovalGroup | Soft-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:
| Operation | V2 config present | V2 config absent |
|---|---|---|
| create | ApprovalGroupController (keeps graph in sync) | ApprovalGroupRepository (row only) |
| update (config + members) | ApprovalGroupRepository — always. | |
| delete | ApprovalGroupController | ApprovalGroupRepository |
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
- USER members must be ACTIVE.
findInvalidUserIdschecks org +deletedAt: null+status: 'ACTIVE'for every newly-added userId. - requiredApprovals ≤ member count. Otherwise the group "could never be approved" — refused with a specific error.
- Deduped assignees. Duplicate approvers would inflate count vs. the engine's
uniqBy(userId)at execution time, making thresholds unsatisfiable. - Case-insensitive unique name on create / rename.
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:
Both draft and published, via loadForPreview + loadV3WorkflowSpec. Deduped by edge id.
Count of nodes in the V2 APPROVAL_RULE workflow config whose destination === groupId.
pendingApprovalAction rows in PENDING state on this group.
approvalReplacementRule rows whose approvalGroupIds array contains this group.
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.
Opened via getApprovalGroupDetails(groupId) → its prefill object is passed verbatim to openApprovalGroupModal. The card opens populated with current name, settings, and member chips.
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
- Submit disabled when
requiredApprovals > members.length; inline error message. - Searchable picker filters to
status === 'ACTIVE'users. - Manager-level chips are read-only (rendered with the
managerbadge variant) — managed elsewhere, preserved on save. - Trimmed empty strings are forwarded as-is (so clearing a field actually clears it server-side;
|| undefinedwould make a cleared field a no-op).
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
- Approval RULES still draft. The workflowSpec / draft / publish lifecycle for edges is unchanged.
- Product Catalog and Flow Config sub-agents. Untouched.
- Existing approval-rule tools.
createApprovalRuleTools,bulkWriteApprovalRulesstill in place; group tools are additive on the same sub-agent. - Manager-level approver creation from the card. v1 card adds USER members only; existing managers are displayed and preserved, but the "add a manager-level approver" affordance is fast-follow.
- User Management agent never writes. No tools for create/update/delete user. Read-only by design.
- Stytch authoritative for emails. Same data path as
user.listtRPC route.
10. Risks & open questions
addGroupMembers / setGroupMembers / deleteApprovalGroup without the user having actually agreed to that specific change.
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.