The app read as a control panel. Every activity a stage allowed was
rendered as an equal button, so operating it required already knowing
the workflow — which is the opposite of what a workflow tool is for.
Two concrete failures, both visible in the data:
Ops on Document collection got SIX equal buttons. Exactly one was the
next step: upload the three documents. Capture Motor Risk, Capture SME
Risk, AI Cover Recommendation and Generate Quote are the AI's own chain,
present only so a stalled lead can be pushed by hand.
And FOUR stages offered a partner agent nothing but "Mark Lost" — New
Lead, Contacted, Underwriting referral, Premium confirmation. At each of
those the system was actively working the lead, and the only thing the
app suggested was giving up on it.
So an activity now carries what it IS at a stage, not only who may press
it:
do the step this stage is waiting on. Usually one. Referred has
two, because clear and decline are a decision pair rather than
a step and the option of not taking it.
again a bounded loop — retry, reminder, re-quote. Legitimate, never
the answer to "what now".
force an AI employee's own job, offered to a person only so a stalled
lead can be moved. Folded behind "Lead not moving?".
exit Mark Lost. Always reachable, never presented as a step.
The panel is "Next step". With a step, one button at full weight. Without
one it says what is actually happening and who has it, which is the true
answer at six of the nine working stages and was previously rendered as
an empty bar or a lone Mark Lost.
Result per role, checked rather than assumed: an underwriter sees two
buttons, on one stage, and nothing anywhere else. A partner agent sees a
step at three stages. Ops sees a step at four and can still force any
AI activity from the folded section.
Nothing about permissions changed, and the action list still matches
tbl_wf_state_allowed_activities exactly — this is only about which of
them is presented as the thing to do.
Submitting the new-lead form with a blank Product Line produced:
400 The workflow rejected this submission.
validation failed for activity zk-act-init-agent: product_line_4(required)
That asks an operator to know what a field_s_id is, that `_4` is a
disambiguating suffix and not part of a name, that "required" is a rule
rather than a value, and that 400 means them and not us. All four are
ours to know.
Two changes, and the first means the second is rarely reached.
The form now checks required fields before it sends anything. It marks
each empty one — on the label, because somebody scanning a thirteen-field
form is looking for the NAME they missed, not for a red box — scrolls the
first into view, and says "Product Line is required". No round trip, and
nothing to decode. The marking clears as the field is filled, since a
field still flagged after being corrected teaches people to ignore the
flagging.
If the workflow does reject a submission on fields — it validates more
than this form can know about — describeValidation now renders the reply
in the form's own labels. It parses every `key(reason)` pair, maps the
reason to a sentence, and resolves the key through the schema, including
the suffix rule the prefill seeding uses. A key the schema does not carry
keeps its raw name: a wrong label is worse than an ugly one, because the
operator goes looking for a field that is not there.
The status code is dropped from that surface. "400" is the first thing
read and the least useful thing shown; it stays on the errors an operator
genuinely cannot fix by typing.
Four things, and the first is the one that mattered.
ROLE-BASED ACCESS. The console showed every action to everyone on
purpose — "the refusal is the demo" — and the workflow refused
server-side. That is a defensible engineering position and a poor
product: an underwriter saw a row of six buttons, five of which 403.
src/api/permissions.js now mirrors tbl_wf_activity_permissions, and the
session already carries user.roles, so nothing new had to be fetched.
Action buttons, sidebar queues and the entry doors are all filtered.
It remains presentation only — the platform still refuses, and a role
added there but missing here hides a button that would have worked,
which is the failure mode to watch.
Each role's app now looks like their job. Ops sees the whole board and
still cannot clear a referral. An underwriter sees one queue and two
buttons. Agents see the three queues they work in. Reporting is NOT
gated: the overview counts the whole portfolio for everyone, because an
agent tracking leads they filed is reasonable and acting on them is not.
One deliberate divergence from the workflow, commented where it lives:
the two scheduled activities carry no roles at all, because that is the
only configuration under which the scheduler can perform them. Open to
the SYSTEM is not open to everyone signing in, so the UI narrows them —
otherwise an underwriter is offered "Send Document Reminder".
ANALYTICS. The overview was three lists. It is now a measured page:
open leads, pipeline value, written premium, conversion, commission —
then renewal exposure in three buckets, the queues needing a person with
the age of the oldest item in each, and stage distribution as bars.
Checked against the live book rather than assumed, which found a real
bug before it shipped: Policy Issued is written business AND not yet
terminal, so its premium was counted in pipeline and production both.
₹42,912 double counted on 37 leads. Pipeline now excludes anything
already on risk.
The lead page gained the same treatment: renewal countdown, lead age,
time in current stage, premium, commission, AI confidence. Age and
dwell are computed — neither is on the record, and they are the two
figures that answer "is this moving?", which no field could.
TERMINOLOGY. The previous pass over-corrected: fixing builder jargon
("the workflow decides, server-side") produced chat ("What you can do",
"The agents have it", "Nothing here right now"). Neither is how an
operations console reads. Now: Actions · Record · Audit trail · Action
required · Customer response · Automated · Scheduled · Document
collection · Underwriting referral · Premium confirmation.
LAYOUT. Prose subtitles cut to one line or removed. Measures render as
tabular figures in a bordered strip instead of label/value pairs. The
overview splits into work on the left and distribution on the right.
Stage bars replace a wall of equal-weight cards.
Pre-existing lint errors unchanged at 11; none in the new files.
Three problems, and the first one is the reason the other two were hard
to see.
THERE WAS NO DASHBOARD. "/" redirected to the underwriting queue — one
stage, usually empty, and belonging to somebody else. Every screen in
the app answered "show me this queue"; none answered "how is the book
doing, and is anything waiting on me?", which is the only question a
person has before they have picked a queue.
So: a Today page. Three blocks, in the order somebody cares about them.
What is waiting on a person, as three cards that colour only when they
have something in them. Renewals running out, worst first, capped at 30
days because a renewal six months away is not a thing to look at today.
Then every stage with its count, zeroes included — a stage quietly
receiving nothing is only visible if its zero is on screen.
It is one list call, counted in the browser. That is honest at this size
and it is the same call the queues already make. If the book outgrows it
the answer is a counts endpoint, not a bigger page.
THE WORDS WERE WRITTEN FOR WHOEVER BUILT THE WORKFLOW. "What happens
next" was followed by "these are the only activities this state allows
— but the workflow decides, server-side, whether you may". A failed list
said "this queue has no record view yet". Panels were called "Flow
details". A closed lead read "nothing runs from here". None of that is
wrong; all of it is addressed to the wrong reader.
Now: "What you can do", "Lead details", "History", "This queue could not
be loaded". Queue names say what they want — "Documents needed",
"Underwriter to review", "Payment to confirm". The sidebar group called
"Not yet" says "Not due yet", and "Running by itself" says "The agents
have it", which is the actual claim being made.
THE QUEUE HAD NINE COLUMNS AND LED WITH THE WRONG ONE. Two were internal
vocabulary: attribution status renders "clear" or "contested" and means
nothing to an operator, and the channel is background rather than
something anyone scans a queue for. Both fold into a subtitle under the
customer. That leaves six columns and puts the renewal countdown — the
number that decides whether to act today — second instead of fifth.
Also: the timeline printed a raw slug when a step came back without a
name. It is the screen this product is demonstrated on, so a
"zk-act-doc-reminder" in the middle of an otherwise readable story is
expensive. It now reads as English.
The workflow now schedules work for itself. Two activities exist that
nobody navigates to — the scheduler performs them — and both are offered
here as well, because a person needs to be able to do by hand what the
timer does on its own.
Retry Call, in Awaiting Contact. Booked automatically four hours after an
unanswered call, clamped into 09:00-21:00 IST, three attempts and then
the lead closes. It records the attempt; whether it also DIALS is gated
on the lead's `auto_retry` field, which ships unset, so nothing rings a
phone until somebody arms it deliberately.
Send Document Reminder, in Document Pending. Fires at 24h, 72h and 120h
from Engage's request. On the third unanswered one the lead leaves the
queue — Parked if the renewal is still weeks off, Lost if the cover is
about to lapse. That is the bound: a chase with no ceiling fills a book
with leads nobody will ever work, all of them looking active.
Both are labelled "Scheduled — or you" rather than given an AI or a role,
because neither is true. The performer on the timeline will read System.
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.
Issue Payment Link is now Request Premium — the platform has no payment
integration and the fabricated link field is off the form. The action's
'by' was 'Payment webhook', which was aspirational; collection happens on
the insurer's own rails and ops records the reference.
Log Payment Nudge added to Payment Pending. Engage performs it; without an
entry here the activity is invisible to the console rather than broken.
/upload requires it and it is fixed for this app, but the client only
appended it when ctx happened to carry it — and FileField passes only
activity, field and instance. Every upload came back 400 with
"Missing required params: workflow_uuid".
Taken from config now, so it cannot be forgotten by a caller.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collect Documents shipped with every document field rendering as a plain
text input — ActivityForm had no case for `file` or `ocr`, so they fell
through to the default. There was a form asking you to type an RC book.
Uploads happen ON PICK rather than on submit, because /ocr-extract takes
a REFERENCE to a stored file: the document crosses the wire once, it
survives a reload (a draft cannot carry a File), and re-extracting costs
no second upload.
For an ocr field the extracted values are shown inline — "read from the
document" — and filled into the fields they map to. Only into fields that
are still blank: a value a person typed is never overwritten by one a
model read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The activity was added to the workflow on both Contacted and Proposal
Accepted, but not to the console's action map — so the platform allowed
it, the console never offered it, and nothing errored anywhere. There was
simply no way to upload a document.
Notes the hazard on the map itself: it is hand-maintained against
tbl_wf_state_allowed_activities, and anything missing from it is
invisible rather than broken.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lead file listed the current field values and nothing about how they
got there. On an app whose whole claim is that agents do the work, that
is the wrong thing to show: you could see the outcome and not whether a
machine or a person produced it.
Adds a timeline from the audit trail — every activity in order, badged by
actor, carrying the reasoning the agent actually wrote (attribution,
eligibility, call notes, recommendation rationale, the premium breakup)
rather than the raw field dump that is already below it.
Colour encodes something real: machine work is blue, people amber, the
platform grey, so a file reads at a glance for how much of it was done by
hand.
Also fixes the audit path — the bare /view/audit is not an API route and
falls through to the SPA, returning HTML with a 200 that parses as a JSON
error rather than an HTTP one. It has to be app-scoped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This demo is about the individual agent who sources a lead and gets paid
when it onboards, so the console offers that one entry point and goes
straight to its form rather than showing a chooser with one option.
The direct and bancassurance INIT activities are hidden, not removed —
both still work over the API with their own permissions, and the
bancassurance door is what makes the cross-channel duplicate story
possible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pipeline could be looked at but not driven. Adds the three pieces
that make the machine walkable end to end from the UI.
- ActivityForm renders whatever /view/form-screens returns — labels,
types, select options, mandatory flags — and submits it back. No form
is defined in this repo, so a field added in Studio appears here with
no code change.
- AddLead presents the three doors. Each is a separate INIT activity with
its own permissions; the bank one lands further along because the bank
already did CKYC.
- Lead shows the file grouped in the order it was worked, ending with
what the sourcing agent earns, plus the activities this state allows
and who normally performs each.
The "who normally performs this" label is presentational only. Nothing
here enforces anything — the workflow refuses server-side and the form
reports what it said, including a note when a 403 is the platform
declining rather than the console misbehaving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The POST body is not the GET query params with a different verb. The view
is named by rv_template_uid (rv_id is the GET spelling) and paging, sort
and filters all live inside search_query. Sending them at the top level
returns 400 "Missing param: rv_template_uid (body)".
This would have failed the moment the record view was seeded, and the
400 would have read as "the view is missing" rather than "the request
was wrong" — the queue screen renders exactly that notice on any error.
Verified against dev: the old shape returns the missing-param 400, the
new one returns "record view not found", which is the view genuinely not
existing yet.
Also adds rows(), which normalises the response key — workflow views
return `data`, rdbms views have been seen returning `records`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scaffold had the brand right and nothing behind it: the login was a
600ms setTimeout with a TODO, and three pieces of the frontgen deploy
contract were missing.
- Sign in against POST /usr/login (org_id as a STRING — a number is
rejected by the gateway) with the session in a provider; surface the
gateway's own message rather than a generic "invalid credentials",
because a wrong password and a user without access to this app look
identical from here and are not.
- Runtime config: the API URL is read from the config.js the server
writes at placement, never compiled in, and requireConfigValue throws
so a build with no config.js fails loudly instead of calling whichever
backend built it.
- base: './' plus a router basename taken from <base href>, so one build
serves any mount path.
- Move the fonts and logo from public/ into src/assets/ — Vite rewrites
bundled asset URLs to be relative, while a public/ file referenced as
"/fonts/..." stays absolute and 404s under the /zurich-kotak/ mount.
- API client for recordview / detailview / form-screens / start /
activity, and the pipeline shell whose sidebar is the state machine in
the order a lead moves.
Queue and lead-file data wait on the record and detail views.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>