config: the v2 state model

The workflow dropped four stages on 7 September and renamed two. This
table is the console's mirror of it and was still describing the old
shape, which breaks quietly rather than loudly.

WHAT CHANGED IN THE WORKFLOW

Risk Captured, Proposal Accepted, KYC Verified and Underwriting Cleared
are gone. A lead entered and left each of them inside a minute, so they
were pipeline steps wearing a stage's clothes. Meanwhile the one genuine
stall -- a partner agent finding three documents -- had no stage at all
and happened inside "Contacted", which meant the queue that most needed
watching was the only one that did not exist. zk-state-docs is that
queue, and it is where the work those four stages used to represent now
happens: upload, capture, advise, quote, all without the lead moving.

Quote Presented absorbs the other three the same way -- acceptance,
identity and the underwriting screen -- and leaves in one move for
Payment Pending, Referred or Declined, decided by a single gate that
reads both answers. The two-gate chain it replaces could not express
"identity refers but the risk is fine": the first gate routed to
Referred before appetite had been asked, so an underwriter always
received half a file.

Qualified became Awaiting Contact and Quoted became Quote Presented.
"Qualified" meant pre-call here and post-call on the whiteboard -- the
same word on opposite sides of the dial.

WHY THE RENAME IS THE DANGEROUS HALF

STAGES.name is compared against current_state_name in three places: the
queue filter, the sidebar tally, and the action lookup on the lead
detail page. A workflow rename that is not mirrored here does not error
anywhere -- the record view returns zero rows, correctly, for a name no
lead is in. Both renamed queues would simply have read empty. The header
comment now says so.

Worse, zk-state-docs was absent from STATE_ACTIVITIES entirely, so a
lead sitting there rendered no buttons at all. Document upload is the
human step the entire rest of the flow hangs off.

ALSO

Parked / Nurture has been in the workflow since 4 September and was
never added here, so its Resume Outreach button did not exist. Until a
scheduler lands, that button is the only thing that wakes a parked lead,
so it gets a nav group of its own rather than being filed under
"running by itself", which it is not.

DROPPABLE follows the new state list. Mark Lost now also reaches
Referred, which the workflow had never allowed and now does: a lead
whose cover lapses while an underwriter holds the file previously had
nowhere to go.

doc_vehicle_photos becomes conditional. The comment said no break_in
flag existed to test -- one does now, derived at filing on all three
doors, so the slot honours its own "break-in only" caveat instead of
being shown to every motor renewal.

Verified by diffing STAGES.name and ACTIONS against tbl_wf_states and
tbl_wf_state_allowed_activities on dev; both match exactly.
This commit is contained in:
Yashas 2026-09-07 12:22:42 +05:30
parent 66ea08e317
commit b9c76482a2

View File

@ -13,7 +13,7 @@ export const WORKFLOW = 'zk_wf_lead'
* *
* Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched * Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched
* because the sidebar has to render its ORDER, and the API returns states as a * because the sidebar has to render its ORDER, and the API returns states as a
* set with no canonical sequence. Keep in step with 02_workflow.sql. * set with no canonical sequence. Keep in step with 53_v2_state_model.sql.
* *
* `kind` answers "who is holding this lead", which is the question an operator * `kind` answers "who is holding this lead", which is the question an operator
* actually has not "where is it in the process": * actually has not "where is it in the process":
@ -21,25 +21,36 @@ export const WORKFLOW = 'zk_wf_lead'
* needs a person has to act, and nothing moves until they do * needs a person has to act, and nothing moves until they do
* customer waiting on someone outside the business * customer waiting on someone outside the business
* auto an AI employee is carrying it; `doing` is what to say meanwhile * auto an AI employee is carrying it; `doing` is what to say meanwhile
* waiting real, but not yet the renewal is too far off to work
* end terminal * end terminal
* *
* `need` names the queue by what it wants done. A queue called "Upload * `need` names the queue by what it wants done. A queue called "Upload
* documents" answers "is anything waiting on me?"; one called "Contacted" * documents" answers "is anything waiting on me?"; one called "Document
* describes where the lead sits and leaves the operator to work it out. * Pending" describes where the lead sits and leaves the operator to work it out.
*
* `name` MUST match workflow.tbl_wf_states.name exactly the queue filter, the
* sidebar tally and the action lookup all resolve a lead through this string
* against current_state_name. A workflow rename not mirrored here does NOT
* error: the queue returns nothing, correctly, for a name no lead is in. That
* is the failure mode this table has, and it is silent.
*/ */
export const STAGES = [ export const STAGES = [
{ uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Qualifying…', by: 'Intake AI' }, { uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Qualifying…', by: 'Intake AI' },
{ uid: 'zk-state-qualified', name: 'Qualified', kind: 'auto', doing: 'Calling the customer…', by: 'Voice agent' }, { uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer…', by: 'Voice agent' },
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'needs', need: 'Upload documents', by: 'the partner agent' }, { uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Writing the document request…', by: 'Engage AI' },
{ uid: 'zk-state-risk', name: 'Risk Captured', kind: 'auto', doing: 'Pricing and advising…', by: 'Rating + Advisor AI' }, // The principal stall in the workflow, and the one that had no stage until
{ uid: 'zk-state-quoted', name: 'Quoted', kind: 'customer', need: 'Accept the proposal', by: 'the customer' }, // 7 September: a lead waited for its three documents inside "Contacted", so
{ uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'auto', doing: 'Verifying identity…', by: 'KYC AI' }, // the queue that most needed watching was the one that did not exist.
{ uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'auto', doing: 'Screening for underwriting…', by: 'KYC AI' }, { uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Upload documents', by: 'the partner agent' },
{ uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Accept the proposal', by: 'the customer' },
// The one queue where the machine stops and a person decides. // The one queue where the machine stops and a person decides.
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Clear or decline', by: 'an underwriter' }, { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Clear or decline', by: 'an underwriter' },
{ uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'auto', doing: 'Requesting premium…', by: 'Engage AI' }, { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Confirm premium', by: 'ops' },
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Confirm premium', by: 'ops' }, { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file…', by: 'Engage AI' },
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file…', by: 'Engage AI' }, // Nurture. There is no scheduler yet, so Resume Outreach is a button and it
// is the only thing that wakes a parked lead — do not present this as a
// stage with nothing to do.
{ uid: 'zk-state-parked', name: 'Parked / Nurture', kind: 'waiting', need: 'Resume when the window opens', by: 'you, for now' },
{ uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' }, { uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' },
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' }, { uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' }, { uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
@ -54,6 +65,7 @@ export const NAV_GROUPS = [
{ label: 'Needs someone', kind: 'needs' }, { label: 'Needs someone', kind: 'needs' },
{ label: 'With the customer', kind: 'customer' }, { label: 'With the customer', kind: 'customer' },
{ label: 'Running by itself', kind: 'auto' }, { label: 'Running by itself', kind: 'auto' },
{ label: 'Not yet', kind: 'waiting' },
{ label: 'Closed', kind: 'end' }, { label: 'Closed', kind: 'end' },
] ]
@ -87,25 +99,42 @@ export const DV_LEAD = 'zk-dv-lead'
const STATE_ACTIVITIES = { const STATE_ACTIVITIES = {
'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }], 'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }],
'zk-state-qualified': [{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI' }], 'zk-state-qualified': [{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI' }],
'zk-state-contacted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' }, // Contacted has exactly one way forward and it belongs to Engage. An AI
// cannot receive a file, so it writes the ask; the agent uploads one state
// later. Giving the AI the activity that RECEIVES documents is how you get an
// agent recording documents it never saw.
'zk-state-contacted': [{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI' }],
// Four steps that used to be two stages: the upload, the capture, the advice
// and the quote. The lead stays here for all of them and leaves only once a
// premium exists.
'zk-state-docs': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — upload here' },
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' },
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' }, { uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' },
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' }], { uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' },
'zk-state-risk': [{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' },
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' }], { uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' }],
'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' }], // Likewise absorbs acceptance, identity and the underwriting screen — none of
'zk-state-accepted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' }, // which was ever a stage a lead rested in. Generate Quote appears again as a
{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' }], // RE-quote for a customer who wants different cover; the lead does not move,
'zk-state-kyc': [{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'Rules' }], // and quote_revisions counts them (0 on the first, 2 is the ceiling).
'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' },
{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' },
{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'KYC AI' },
{ uid: 'zk-act-quote', label: 'Generate Quote (revise)', by: 'Engage AI' },
{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' }],
'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' }, 'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' },
{ uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' }, { uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' },
{ uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }], { uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }],
'zk-state-cleared': [{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Ops' }],
// 'Payment webhook' was aspirational — there is no payment integration on the // 'Payment webhook' was aspirational — there is no payment integration on the
// platform and none is planned here. Collection happens on the insurer's own // platform and none is planned here. Collection happens on the insurer's own
// rails; ops records the reference. See 35_reframe_to_what_exists.sql. // rails; ops records the reference. See 35_reframe_to_what_exists.sql.
'zk-state-payment': [{ uid: 'zk-act-realise', label: 'Confirm Premium Realisation', by: 'Ops' }, //
{ uid: 'zk-act-nudge', label: 'Log Payment Nudge', by: 'Engage' }], // Request Premium moved here when Underwriting Cleared was removed: entering
'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Ops' }], // this state IS the decision to collect, so the ask happens inside it.
'zk-state-payment': [{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Engage AI' },
{ uid: 'zk-act-nudge', label: 'Log Payment Nudge', by: 'Engage AI' },
{ uid: 'zk-act-realise', label: 'Confirm Premium Realisation', by: 'Ops' }],
'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Engage AI' }],
'zk-state-parked': [{ uid: 'zk-act-resume', label: 'Resume Outreach', by: 'Whoever owns the book' }],
} }
/** /**
@ -120,9 +149,8 @@ const STATE_ACTIVITIES = {
const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'Whoever holds the lead' } const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'Whoever holds the lead' }
const DROPPABLE = [ const DROPPABLE = [
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-risk', 'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
'zk-state-quoted', 'zk-state-accepted', 'zk-state-kyc', 'zk-state-referred', 'zk-state-quoted', 'zk-state-referred', 'zk-state-payment', 'zk-state-parked',
'zk-state-cleared', 'zk-state-payment',
] ]
export const ACTIONS = Object.fromEntries( export const ACTIONS = Object.fromEntries(
@ -178,9 +206,10 @@ export const DOC_SLOTS = {
// Motor. // Motor.
doc_rc: { line: 'motor' }, doc_rc: { line: 'motor' },
doc_prev_policy: { line: 'motor' }, doc_prev_policy: { line: 'motor' },
// "Break-in only" per the policy, but no break_in flag exists on the instance // "Break-in only" per the policy. The flag DOES exist now — 49 derives
// to test — the field name carries the caveat and the slot stays on motor. // break_in at filing on all three doors — so the slot can honour its own
doc_vehicle_photos: { line: 'motor' }, // caveat instead of being shown to every motor renewal.
doc_vehicle_photos: { line: 'motor', when: (lead) => lead.break_in === 'yes' },
// SME. // SME.
doc_gst_cert: { line: 'sme' }, doc_gst_cert: { line: 'sme' },