V3 approval-rule editor: full editor in chat, prefilled by the agent
The Config Agent's in-chat approval-rules card becomes the same multi-route editor used on the admin page, with a validate-only proposeApprovalEdge tool feeding the prefill.
1. Why this exists
The V3 Config Agent already had a chat-side card for approval rules — a small form that popped up in the conversation so the user could confirm a rule the agent had drafted. But the card was a degenerate version of the real editor:
- One condition. No nested AND/OR.
- One destination, sort of. (Tiered approvals like 10%→Sales Mgr, 20%→Deal Desk, 30%→CRO were not expressible in a single rule.)
- Limited operator set; entity scoping (sku vs quote vs opportunity) glossed over.
The orchestrator had a hard-coded ban on opening the card at all — see the old prompt text: ⚠️ NEVER open a modal with modalType: "approval_rules" — that modal is currently disabled while its UX is rebuilt. The agent fell back to a sequence of structured questions, which made approval-rule authoring noticeably worse than every other config flow.
This PR replaces the chat card with the same editor used on the admin page, factored into a shared component, and threads a validate-only "propose" tool through the agent so the card opens prefilled with a draft that's guaranteed to be persistable.
2. Before / after
- Chat card: ~660 lines of bespoke single-condition form code.
- Admin page: ~270 lines of editor logic inline in ApprovalRulesView.
- Two divergent implementations of "edit a routing edge".
- Card disabled at the orchestrator. A "Quick Add" path on the admin page.
- Single condition, single destination per row.
- One shared component: EdgeEditorFields.tsx (~285 lines).
- Admin page wrapper drops to ~190 lines; chat card wrapper to ~140.
- Single source of truth for the editor UI.
- Quick Add removed. Orchestrator opens the card for every create/edit.
- Multi-route rules, nested AND/OR, all operators, per-condition entity.
3. What changes
Shared editor body: EdgeEditorFields.tsx (new, +285)
Renders Description + Trigger (Pill Target) + the existing ConditionBlockEditor. Controlled — parent owns state, passes onChange. No Sheet, footer, or persistence. Both wrappers supply those.
It also exports a pure validity predicate that mirrors the server's validation, so the Submit button only enables for edges the create/edit tools will actually accept:
export function isEdgeValid(edge: WorkflowEdge): boolean {
const t = edge.trigger;
if (!t?.type) return false;
if (t.type === 'term' && !t.termId) return false;
if (t.type === 'quote' && !t.path) return false;
return edge.routes.length > 0 && edge.routes.every(r =>
!!r.destination?.groupId && r.blocks.length > 0 && r.blocks.every(b =>
b.conditions.length > 0 &&
b.conditions.every(isConditionComplete) &&
(t.type !== 'product' ||
b.conditions.every(c => c.entity !== 'quote' && c.entity !== 'opportunity'))));
}
ApprovalRulesModal.tsx rewritten (−665, +109)
Old: a giant component with its own trigger/operator/value/destination state, a half-broken term-vs-flowSpec value translation, and a "destinations" array hacked on top. New: state is a single WorkflowEdge, passed straight to <EdgeEditorFields>. Submit forwards { mode, edge } back to the chat.
ApprovalRulesView.tsx trimmed (−265, +20)
The admin page's inline EdgeEditorSheet body collapses to four lines of JSX around the shared component. The "Quick Add" button and its ModalHost wiring are gone — the full editor is fast enough.
ConditionBlockEditor.tsx made null-safe + two-line rows (+142/−122)
Condition rows reflow to a two-line layout (entity+attribute on top, operator+value+remove below) so select menus can show full option text instead of truncating. Numeric inputs now store the raw string while editing; normalizeEdge coerces to a number on persist:
// Raw string while editing (so intermediate states like "-" / "1." aren't
// lost to a Number() coercion); normalizeEdge coerces to a number on persist.
min?: number | string;
max?: number | string;
Every .map / array access has a ?? [] guard so a partially-formed proposed edge can't crash render before the user fills it in.
New: approvalRuleLogic.ts (+209, pure)
Three exported functions, no IO:
- validateRoutesShape(routes) — at least one route, each with ≥1 block and a destination carrying groupId + numeric chainOrder. Returns first error or null.
- validateEdgeInput(input) — full structural validation: trigger required, term needs termId, quote needs path, product-trigger edges can't reference quote/opportunity conditions, every condition has a complete target (operator-appropriate value).
- normalizeEdge(input) — builds the persisted V3WorkflowEdge: drops empty groupName, auto-fixes in/not_in when value is an array, coerces numeric strings on numeric operators.
New tools in approvalRuleTools.ts
Shared Zod schemas, shared IO checks
addApprovalEdge is refactored to share two new schema constants (EDGE_ROUTES_SCHEMA, EDGE_TRIGGER_SCHEMA) and a helper:
// IO-dependent edge validation shared by addApprovalEdge / proposeApprovalEdge
// / updateApprovalEdge: product-name references (soft warnings) + term-trigger
// values vs flowSpec options (hard error).
async function validateEdgeIO(input): Promise<{ error?: string; warnings: string[] }>
Net: propose, create, and full-edit can never drift in what they accept.
Backstop on edge IDs
// The create card omits id and relies on the orchestrator to mint one — mint
// it here too so a blank id can't round-trip into the spec.
const edgeId = id && id.trim() ? id.trim() : `edge-${Date.now()}`;
Sub-agent summarizer keeps proposedEdge
The sub-agent → orchestrator response goes through a whitelist summarizer. A new field is added to the allow-list:
// Carries the candidate edge from approvalRuleAgent's proposeApprovalEdge /
// getApprovalEdge up to the orchestrator so it can pre-fill the approval-rule
// editor card. Without keeping it here, the edge is stripped before the
// orchestrator ever sees it and the card opens empty.
'proposedEdge',
Orchestrator prompt: ban → flow
The old "NEVER open a modal with modalType: approval_rules" rule is gone. In its place, a four-step "APPROVAL RULE CARD" flow:
| Step | What the orchestrator does |
|---|---|
| 1. Create | Ask the sub-agent to propose an edge for the user's request. Sub-agent calls proposeApprovalEdge, returns the candidate in proposedEdge. Orchestrator opens the card prefilled with that edge (id stripped). |
| 2. Edit | Sub-agent calls getApprovalEdge(edgeId), returns it in proposedEdge. Card opens prefilled with mode: 'edit'. |
| 3. Submit | Next message is [Modal:approval_rules] with { mode, edge }. Orchestrator delegates the write — addApprovalEdge on create, updateApprovalEdge on edit. Conflicts surfaced. |
| 4. Help | [Modal:approval_rules help] with the user's NL refinement → re-propose, re-open card with fresh requestId. |
approvalRuleAgent prompt
The tool list gains proposeApprovalEdge and updateApprovalEdge, with explicit guidance: when the orchestrator says "propose," call proposeApprovalEdge and put the nested edge (not the whole wrapper) on proposedEdge. There's a specific footgun called out — pass the wrapper and the card opens blank.
ApprovalGraphView.tsx: per-route rows (+68/−69)
Old: one row per edge, with all the edge's route conditions flattened together into one box. A tiered rule (>10→VP, >20→Deal Desk, >30→VP) collapsed to a single illegible row.
New: one row per route. Same tiered rule renders as three distinct rows, each showing only its own conditions, each pointing to its own destination node.
// Flatten edges → routes: one row per ROUTE (a route is one condition
// group → one destination). An edge with N routes becomes N rows, so a
// tiered rule (>10→VP, >20→Deal Desk, >30→VP) renders as 3 distinct rows.
Group nodes are deduped and stacked in their own column ordered by chain order, then vertically centered. Two routes landing on the same group draw into the one (deduped) group node, so the visual still reads as "this group only fires once" even when two route rows feed it.
4. The propose → confirm → write handshake
The end-to-end flow is the heart of the PR. Five actors, five steps.
5. How parity is enforced
The propose/create/edit invariant has three load-bearing pieces:
6. Subtle correctness fixes
7. What it doesn't change
- The approval-engine runtime — predicates, chain ordering, evaluation order are untouched. This is editor-side only.
- The WorkflowSpec persisted shape — V3WorkflowEdge / V3TriggerTarget are unchanged. Existing draft and live specs continue to load.
- The narrower update tools — updateEdgeRoutes, updateEdgeMetadata, cloneApprovalEdge, removeApprovalEdge, bulkWriteApprovalRules all still exist for narrow tweaks. The card just doesn't use them.
- Approval-group CRUD, conflict detection, evaluator test-context tooling — all unchanged.
- Modal types other than approval_rules — the orchestrator's modal guide for product/term/flow modals is untouched.