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.

PR dealops#5980 Author @yijunz166 Base main Head yijunz/v3-approval-rule-editor Files 14 +/− +1383 / −1347 Area Dealops 3 / V3Admin

Problem
The chat-card approval editor was a simplified single-condition form. It couldn't express tiered routes (10%→Sales Mgr, 20%→Deal Desk, 30%→CRO), nested AND/OR, or per-condition entity scoping. The agent layer had it disabled outright.
Fix
Both surfaces — admin page and chat card — now render the same EdgeEditorFields component. The agent pre-fills it via a new validate-only proposeApprovalEdge tool. Nothing writes until the user submits the card.
Guarantee
A pure logic module (approvalRuleLogic.ts) is shared by propose, create, and update tools. What the agent proposes always persists cleanly when the user hits Submit.
Orchestrator
Approval Rule Sub-Agent
Chat Card (user)
WorkflowSpec (DB draft)
Pure logic (no IO)

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:

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

Before
  • 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.
After
  • 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.

4. The propose → confirm → write handshake

The end-to-end flow is the heart of the PR. Five actors, five steps.

1
UserOrchestrator
"Route discounts above 20% to Deal Desk, above 30% to the CRO."
2
OrchestratorSub-agent
"Propose an approval edge for: …". Sub-agent calls proposeApprovalEdge with a candidate. validateEdgeInput + validateEdgeIO run; on success, normalizeEdge returns the canonical shape. Nothing is written. Sub-agent returns { proposedEdge: <edge> }.
3
OrchestratorChat card
Orchestrator calls openApprovalRulesModal with prefill = { edge, mode: 'create', availableApprovalGroups }. Card opens, fully populated, on the same shared editor body as the admin page.
4
UserOrchestrator
User tweaks, hits Submit (isEdgeValid already gates the button). Chat receives a [Modal:approval_rules] message carrying { mode, edge }. Or: user hits "I don't know how to configure this," sends an NL refinement, and the orchestrator loops back to step 2.
5
OrchestratorSub-agentWorkflowSpec
Orchestrator delegates the write. Create → addApprovalEdge. Edit → updateApprovalEdge (re-runs full create-time validation). Both call saveDraft under withSpecLock.

5. How parity is enforced

The propose/create/edit invariant has three load-bearing pieces:

Shared pure logic
validateEdgeInput, validateRoutesShape, and normalizeEdge live in one IO-free module. All three tools call them. 256 lines of unit tests in approvalRuleLogic.spec.ts cover the edge cases (empty min string vs Number('')===0 trap; non-numeric values like "-"; in with array on value).
Shared Zod schemas
EDGE_ROUTES_SCHEMA and EDGE_TRIGGER_SCHEMA are referenced from all three tool definitions. Schema drift is structurally impossible.
Shared IO helper
validateEdgeIO centralizes the two checks that need DB access (product-name catalog lookup, term-value-vs-flowSpec). Each tool calls it after the pure validator.

6. Subtle correctness fixes

Numeric inputs don't silently NaN. Typing - in a "greater than" field used to coerce to NaN and silently persist. Now the raw string is stored while editing; isConditionComplete rejects non-finite values so the Submit button stays disabled until you finish typing the number.
Empty min doesn't sneak through "between". A naive Number.isFinite(Number(x)) check wrongly accepts '' (Number('') === 0). The new hasFiniteNumber helper rejects it explicitly.
proposedEdge survives summarization. Sub-agent responses pass through a field whitelist before the orchestrator sees them. Without adding proposedEdge to that list, the prefill gets stripped and the card opens empty. There's a dedicated test for this exact path.
Don't pass the wrapper as the prefill. proposeApprovalEdge returns { success, edge, warnings }. The agent prompt explicitly warns: pass only the nested edge to the card — passing the whole wrapper opens a blank editor because the wrapper has no top-level trigger / routes.

7. What it doesn't change

8. Risks & follow-ups

Field-name discipline matters. The whole prefill pipeline hinges on proposedEdge being literally that string at every hop (sub-agent JSON output, summarizer whitelist, orchestrator handler). A typo on any side silently degrades the card to "opens blank." There's a unit test covering the summarizer hop, but the agent-prompt → JSON-output side is policy-enforced, not type-enforced.
updateEdgeRoutes still exists and skips full validation. Per the agent prompt, the card's edit submit goes through updateApprovalEdge. But the older narrow tool is still available to the sub-agent; if the agent picks it for a card-style edit, the user gets weaker validation than they expected. Worth watching.
The +1383 / −1347 is almost a wash, but isn't a refactor. The line counts make the diff look neutral. It isn't — the card gains the entire route/condition editor surface, the admin page loses a quick-add path, and a new validation module + two new tools come in. The deletions are concentrated in the old single-condition card.