Commit Graph

72 Commits

Author SHA1 Message Date
125b3ffcf6 console: 60/40 split on the lead page — close the dead gap
The rail was a fixed 340px, so the main column ballooned much wider than the
feed text (capped for readability) and a dead gap opened between the narrative
and the facts. Proportional split now: the AI decision stream ~60%, the
customer / vehicle / document facts ~40%. The gap closes, the feed fills its
column (max line length nudged 76→82ch), and the facts breathe — the PDF names
stop wrapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:17:02 +05:30
3cc99b718a console: fix the stale "Upload documents" CTA; refine the chain-of-thought
TWO THINGS.

1. Fix: the header "Your move → Upload documents" kept showing after documents
   were uploaded. The render already demotes Collect Documents from a step to a
   "replace or add" recovery lever once the files are in (docsDone), but the
   header CTA computed primaryAction straight off `actions` and never applied
   that demotion — so it surfaced "Upload documents" over a lead whose docs had
   just landed. The same docs-in check now guards primaryAction.

2. Refine: the chain of thought reads as a distinct AI reasoning artifact now,
   not loose text under a link. The affordance is a tinted "thinking" chip; the
   revealed reasoning sits in a soft accent-tinted panel with a stepped rail and
   markers — the record of how the machine decided, given the weight it deserves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 13:23:51 +05:30
818ef871ba fix(console): blank lead page — statusStrip glued to a split stalled ternary
When statusStrip was moved (fda1247), the regex that placed it after `const
stalled` matched only the FIRST line of stalled's multi-line ternary. The
`? { mins, nudge } : null` continuation was left dangling right after
`const statusStrip = (…)`, so statusStrip parsed as `<jsx> ? {mins,nudge} : null`
— a truthy JSX condition — and evaluated to the object {mins, nudge}. Rendering
that object threw React #31 and blanked the page for any stalled instance;
`stalled` itself was left a bare boolean.

Restored stalled to its object form and removed the orphaned ternary. statusStrip
is a clean JSX expression again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:28:46 +05:30
ee57184fa2 console: rebuild the look in the assured-inspired language (approved direction)
Full styling reset to the approved mockup — from scratch, no functional change.

- PALETTE: clean white ground, warm near-black ink, ONE confident magenta-pink
  accent (#e5227f) reserved for action and active state. The accent keeps the
  --zk-blue-* token names it always had; only the values changed. Teal is the
  customer, amber a person; pink is "act / here", nothing else. Neutrals warmed,
  shadows softened to a plum-ink low alpha.
- TYPE: Plus Jakarta Sans — geometric-humanist, bold on headings (page title
  800, panel titles and figures 700+), the modern-product register assured has.
- SHAPE: the flat single-canvas is gone; back to clean rounded cards that float
  a little on the off-white — panels, metric tiles (money in the accent tint),
  the stage rail, the facts rail. Status ribbon in teal, the primary action a
  pink pill with an accent glow.

Same components, same data, same behaviour — only the surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:24:44 +05:30
fda1247ee9 fix(console): blank lead page — statusStrip const used blocked/stalled before they existed
The lifted status strip (e21e394) was defined right after `holds`, but its JSX
references `blocked` and `stalled`, which are declared ~80 lines lower. A const
that reads a const declared below it throws "Cannot access before
initialization" at render — a temporal dead zone the bundler does not catch, so
the build passed and the lead page rendered blank.

Moved the statusStrip definition to just after `stalled`, so every value it
reads exists first. The lifted-above-the-metrics position is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:13:33 +05:30
e21e3949f5 console: state before numbers; frame optional actions; flatten the overview band
Applying the lens from the status-strip critique across the surfaces.

LEAD PAGE
- "What is happening now" lifts above the metrics. An operator's first question
  is what is going on / what do I do, not what the premium is — so the status
  strip (an alert when blocked or stalled, the live state otherwise) sits
  directly under the header, before the four numbers, full-width.
- The optional "Add a document" action stopped floating: with no required step
  it read like the next move and sat under "nothing is waiting on you". A quiet
  "Optional" tag now frames it.

OVERVIEW
- The "right now" band was four heavy dark-navy shadowed cards — the
  cards-on-canvas language removed everywhere else, clashing on the flat white
  page. Flattened to light tiles read by a coloured left rule (blue AI, amber
  person, teal customer, grey risk), the figure taking that colour; a live
  person-queue or lapsed renewal warms its whole tile. Prominence from the big
  number and the accent, not a dark fill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:01:46 +05:30
d1ed66b7ae console: sparkle for AI, person for human — two clear symbols
The role-icons-plus-corner-spark were busy and didn't read. Replaced with the
two symbols that are actually universal: a sparkle for the machines — the mark
Claude, Gemini and Copilot all use, so an AI step reads as AI with no legend —
in the agent's own colour, and a plain head-and-shoulders person glyph for the
people. The contrast is the whole message; which agent is said by the name
beside the disc and by the colour. Verified crisp at disc size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 11:52:32 +05:30
d43b792dcb console: lift the type scale — body 14, values 15, so hierarchy shows
An audit found 124 of ~200 sizings at 12-13px: the 13px "meta" tier had become
the de-facto body size (97 uses), so the app read tiny and uniform with nothing
for the eye to land on, and the upper scale was starved. Shifted the crowded
lower tiers up one notch — dominant text 13->14, field labels 14->15, values
15->16, emphasis 16->17. One change to the scale definitions, lifting every
surface at once; 12px stays reserved for uppercase eyebrows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 11:49:57 +05:30
c0480e0ae0 console: Geist — a crisp, modern enterprise typeface
Zurich Sans (a soft humanist OTF) read poorly at the data-density sizes this
console runs, and did not carry the modern-enterprise register we are after.
The UI moves to Geist — Vercel's typeface, the register of Linear/Vercel-class
tools: neutral, technical, and sharp at small sizes. Refs and figures use Geist
Mono. Zurich Sans stays in the fallback stack, so a blocked CDN degrades to the
brand face rather than to a system default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 11:44:55 +05:30
e4ca2b92c9 Revert "console: redesign the UI — one card language, icons, filters, live trail"
This reverts commit e90e4204ec.
2026-09-09 11:40:49 +05:30
e90e4204ec console: redesign the UI — one card language, icons, filters, live trail
Visual and information-architecture pass across every screen. No API call,
payload, poll, permission rule or workflow mirror changes; forms still render
from the live schema and the timeline's merge/grouping is unchanged (only the
display order is reversible).

Design language
- Ground + white cards with one hairline and one radius; sentence-case
  headings; shared primitives in index.css (.card .pill .btn .avatar .live).
- One inline icon set (components/Icon.jsx); no more filled navy/red tiles.
- Read-side labels in api/display.js: roles and enum tokens humanized,
  customer initials/tones, money and date formatters. Nothing sent changes.

Shell
- 56px header with brand, global lead search (portfolio rows, "/" to focus)
  and identity chip with a human role label.
- Sidebar rows carry icons and a clear active bar; AI roster at the foot,
  each opening its remit card.

Login
- Operator copy instead of retail copy; centred sign-in card with show/hide
  password; demo persona picker that fills the EMAIL only.

Overview
- "Who holds the work" as four quiet cards that warm only when owed.
- Pipeline flow bar with every stage named beneath it; KPI strip in a card.
- Action rows with chevron / view-only eye; expiry bands filter the table.

Queues
- Filter bar (search, product, channel, expiry band, stalled, hide closed)
  and sortable columns, all client-side on the fetched page.
- Avatars, product/channel chips, stage chips for closed leads, tinted
  stalled rows, count inline with the title.

Lead
- Avatar + chips in the header; metrics card with the stage rail inside,
  and a branch node for Referred / Parked / Lost / Declined.
- Timeline newest-first by default (toggle), the worker's face as the node,
  one footer row per entry, AI markdown rendered as bold.
- "AI team on this lead" panel with per-agent step counts; PDF tags on docs.

Forms & dialogs
- Required marked with an asterisk + legend; "Select…" placeholder; dd Mon
  yyyy hint under date inputs; open activity form in a titled panel;
  one-line dialog footnotes.

Fixes: horizontal overflow at 1024px, boxed "in N days" figure, raw tokens
(sme_underwriter, verbal_call, Rsa), findings cut at "1.".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:37:42 +05:30
2965c47b71 console: consolidate the lead page — say each thing once, weight by importance
The layout placed information by convenience, not importance, and repeated
itself. This is the consolidation pass, no new look.

- METRICS: seven at equal weight became four, money first — Premium, Commission,
  AI confidence, Renewal-countdown. Dropped lead age and time-in-stage (they
  overlapped each other, and the feed already carries timing), and product /
  vehicle (the rail's "The vehicle" holds them). Nothing is lost; every removed
  measure is still on the page once, where it belongs.
- STAGE stops appearing as a metric — the rail is where the lead's position
  lives, and the feed shows each transition.
- NEXT STEP no longer repeats the status. Its heading and "nothing is waiting on
  you" subtitle rendered even when nothing was owed, saying a third time what the
  status strip and the header already said. The heading shows only for a real
  step now; secondary actions (add a document, mark lost) still render, without
  the framing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:46:38 +05:30
17561f3f73 console: the immediate action lives top-right, not below the fold
"What do I do now" was answered only by scrolling past the metrics and stage
rail to the Next step panel. When this user owns the next step it now sits in
the header top-right as a prominent amber button — where the eye goes for the
next move — and pressing it opens the form and scrolls to it. When nothing is
owed, the passive status stays there instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:42:36 +05:30
60f6c97201 fix(console): sharp text — stop compositing content on a transformed layer
Text looked soft across the whole app. Cause: the entrance animation used
transform: translateY, and .shell__main (the entire content area) ran it with
fill-mode both. An element that animated transform stays on a GPU layer, and
text composited on a transformed layer renders blurry in Chrome on Linux — so
every word on the page was soft.

zk-rise is opacity-only now — it fades in without ever moving text onto a
fractional-pixel layer — and the page-wide container no longer animates at all
(a full-page fade on every navigation only softened text while it played, for
nothing). The remaining transforms are hover-only and transient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:36:07 +05:30
edda6e9540 console: give each AI employee a role icon; people keep their initials
The avatars were initials-plus-spark for the machines and bare initials for
people — thin, and it made five differently-skilled agents look like the same
badge in five colours. The convention that reads instantly is icons for
machines, initials for people: the contrast itself tells a human step from an
autonomous one before a name is read, and the symbol says WHICH machine.

Each employee now wears a glyph true to its job — a magnifier for Intake (checks
the lead), a speech bubble for Engage (talks to the customer), a shield for the
Advisor (recommends the cover), an ID badge for KYC (verifies identity), balance
scales for the Referral (prepares a file for a human to weigh). The AI spark
stays at the corner. People keep their initials, which is the right avatar for a
person and now the clear signal that a person, not a machine, did the step.

Verified legible at disc size (20-26px) across all five brand hues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:34:03 +05:30
119bea1e76 fix(console): centre modals via a portal, not inside a transformed ancestor
The New Lead form opened off-centre and its backdrop covered only part of the
screen. Cause: the dialog uses position:fixed, but .shell__main animates
transform on entrance, and an element that has animated transform keeps acting
as the containing block in Chrome even after the animation finishes. So "fixed"
resolved against the 1460px max-width, margin-auto content area — offset by the
sidebar — instead of the viewport.

Every scrim dialog shared this latent bug. Each now renders through
createPortal(…, document.body), so the fixed overlay is always relative to the
viewport regardless of any transformed ancestor. New Lead, the conversation and
call threads, the full lead file, the agent card, and the reasoning dialog all
now centre and dim the whole screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:28:37 +05:30
adef7838fc console: flat single-canvas design language, in the Zurich Kotak brand
The real problem was never the font or the shadows — it was the design LANGUAGE.
Floating white cards on a grey-blue ground is the generic admin-template look,
and no restyle escapes it. This ports the approved mockup: one canvas, sections
set off by hairlines and space, the activity feed as the hero.

- ONE CANVAS. The content ground is white; the sidebar is the only tinted
  surface. Panels drop their border, radius and shadow and become plain
  sections under their own headings.
- The metric strips (lead + overview) stop being caged tiles and become an
  inline stat row between two hairlines — money at display size, the rest
  quieter.
- The stage rail loses its card and becomes a slim inline stepper.
- The property rail flattens: Customer / Vehicle / Documents are hairline-
  divided groups aligned to the edge, headings at label size — it is
  reference, and should not shout over the story.
- "Who holds it" drops from a filled pill to a coloured dot and a line of text.
- Bricolage is gone. Display and body are both Zurich Sans, the brand face;
  headings and figures lean on weight and tracking, not a second family. The
  palette is the ZK blues throughout.

Same data, same functionality — only the surface. Revertible in one push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:21:55 +05:30
83b5795b22 console: the surface pass — display face, depth, and whose move it is
The layout was right but the surface still read as a flat corporate form. Three
levers, all from the approved mockup:

- A DISPLAY FACE (Bricolage Grotesque) on the things the eye lands on — the
  lead name, panel headings, and every figure. Zurich Sans stays the body
  voice; this gives the numbers and titles the tighter, more current character
  the plain grotesque could not. Loaded from Google Fonts with Zurich Sans as
  the fallback, so a blocked CDN degrades to the brand face rather than breaking.

- DEPTH. Panels and the metric tiles lift from the barely-there --sh-sm to a
  real --sh-md with rounder corners, so cards read as objects on a surface
  rather than boxes drawn on paper.

- WHOSE MOVE IT IS. The header said "STAGE / Quote Presented" — a label. It now
  carries a coloured pill: "Right now · With the customer" (teal), "With the AI"
  (blue), "Waiting on a person" (amber). The stage name is in the rail below;
  the pill answers the question an operator actually opens a lead to ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:05:20 +05:30
900f34d11e console: chain of thought, from data the console already has
Follows the previous commit but drops everything that would have needed a
backend change. The chain is built entirely from the audit trail the console
already fetches: the REASONED rung is the employee's own reasoning fields
(attribution_reason, ai_recommendation_rationale, and the rest — already in the
data), and the DECIDED rung is the activity it committed, the state it moved the
lead to, and its confidence (ai_recommendation_confidence, also a real field on
the step). The finding stays visible above the chain.

No dependency on any endpoint change: the tool-call rung and the tbl_ai_decisions
join are gone. What remains is real, self-contained, and ships with a push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:46:42 +05:30
8e86fa7a0a console: the chain of thought is on the page, not behind a link
An agentic console has to answer one question before anyone will rely on it:
why did the AI do that. The reasoning was always recorded — the employees write
it, the platform stores the tools they called — but the trail showed only the
conclusion. An operator either trusted it blind or opened the raw record.

Each AI step now carries its chain of thought, assembled from real data, never
narrated after the fact: what it CHECKED (the tools it called, each marked for
whether it came back with anything), what it REASONED (the employee's own
words), and what it DECIDED (the activity it committed, the state it moved the
lead to, its confidence). The finding — the first sentence — stays visible; the
chain opens under it.

A tool that returned nothing is drawn hollow. That is the Manju Malik failure
made visible: a licence check that came back empty and was reasoned over as if
it were a finding is exactly what wrongly dropped a real lead, and an operator
scanning the chain should see the empty result the machine reasoned from.

The reasoning ships now — it is already in the audit data. The tool rung lights
up once view-service joins tbl_ai_decisions (next commit); until then the chain
shows two rungs and degrades cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:33:18 +05:30
a49ae3d86c console: the story is the page; the reference is the rail
The audit trail is the record of work nobody watched happen — it is what this
product is for — and it sat in a 384px gutter with its own inner scrollbar
beside a full-width panel of form fields. That said, in layout, that the AI's
work was a footnote to the data entry. Exactly backwards.

Swapped. The trail takes the wide column and scrolls with the page under a
heading that says what it is: "What happened". The reference data — customer,
vehicle, documents, the call, the stage's own facts — moves to a 340px sticky
rail, where short key/value pairs belong. The crew who worked the lead sits at
the top of that rail as its own small panel.

The overview now opens on who holds the work rather than on money: four large
numbers on navy — with the AI, waiting on a person, with the customer,
renewals at risk. A live person-queue or a lapsed renewal warms its cell; the
rest stay cool. Money is the outcome; this is the state, and it is the sentence
the whole console exists to say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:04:12 +05:30
9c05e4b26b console: the shell, the trail and the lead header on the same rhythm
Continues 009153f into the surfaces it had not reached.

The top bar was 66px with a hard rule under it — admin chrome. It is 72 now,
on the softer line, with the product name at 16px instead of 15. The sidebar
gets wider gutters and taller rows, so the queue list stops reading as a dense
menu and starts reading as the pipeline it is drawn to represent.

The audit trail is where people spend their time on this screen, so it gets the
most air: entries at 24px apart rather than 18, quoted text with 12/16 padding
instead of 8/12, and the activity name at semibold so an entry has a heading
rather than a first line.

The stage badge was 15px under a 12px caption. It is the single most important
fact about a lead and now reads at 16px semibold, which is legible from across
a desk — which is how it will actually be read in a demo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:56:59 +05:30
009153fc0c console: a type scale, so size can carry meaning again
The app had FIFTY-FOUR distinct font sizes and forty of them sat between
0.58rem and 0.95rem — a three-pixel band. The most-used size was 0.78rem, so
body copy was 12.5px. That is not a hierarchy, it is a fog of small grey text,
and it is the main reason the screen read as a decade old: nothing could be
emphasised because everything was already the same size.

Nine steps now, with real jumps. 12px is the floor and is reserved for
uppercase eyebrows — every one of the 53 declarations sitting there that was
not an eyebrow has been lifted off it. The surfaces people actually read — the
trail's entries, WhatsApp bubbles, field values, table cells — are at body size
rather than meta size.

The hairline cages are gone with them. The lead metrics and the overview KPIs
were seven equal cells in a 1px grid, every value the same size as its label,
which left the reader to do the prioritising themselves on every lead. They are
separated by whitespace now, values at 22px against 12px labels, with premium
and commission larger again: size does the prioritising instead.

Panels breathe at 32px rather than 24, radii went 10/14 -> 12/16/20, borders
dropped to the softer line, and the stage rail, the queue table and the cards
were rebuilt on the same spacing rhythm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:36:21 +05:30
b27141b797 console: mark the AI's work, and give the call its own card
Initials alone do not say a step was autonomous — "EN" reads as a colleague's
badge. On a screen whose whole claim is that the work ran without people, the
one fact every entry has to state is which entries were the machine's. Agent
discs now carry a sparkle, the settled convention for machine-generated work,
so it needs no legend; humans keep a plain disc, because a mark on every actor
would say nothing. Screen readers get "(AI)" said out loud, since they cannot
see it.

The call was the one step nobody could replay from the fields. Everything else
on a lead was typed or derived; this happened out loud, and its outcome sat in
the full file next to the source channel while the words themselves were only
reachable from a button halfway down the trail. It gets its own card, with the
transcript one click from the notes that summarise it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:30:33 +05:30
49d5443c60 console: the page carries what matters, the file holds everything
Fifty-seven fields over thirteen groups sat open on the lead page, which
pushed the audit trail — the part people actually read — a screen and a half
down. Most of it is reference material: nobody opens a lead to find its source
channel. They open it to see who the customer is, what is being insured, what
has been attached, and what this stage turns on.

So the page carries exactly that — four cards at most, the fourth chosen by the
stage the lead is in — and the complete record moves into a drawer behind one
click. Nothing is dropped: the drawer is handed the same computed groups the
page used to render, so the two cannot disagree about a value.

Hiding is only safe while it stays findable, so the trigger names the number it
is holding back rather than saying "more", and the drawer brings a filter over
labels AND values, because Ctrl-F was how people read the open version and a
click breaks that.

Long values are clamped in both places. uw_referral_reason and lost_reason run
to a paragraph, and a paragraph printed raw in a summary card would rebuild the
wall this exists to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:21:56 +05:30
db91daabf8 console: the receipt is gone from the workflow, so stop describing it
The conversation panel told the reader that two automatic sends were absent
from the thread. Only one is now — 97 removed the "I have your message"
receipt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:57:24 +05:30
ea2b56e332 console: the file reads as facts, and filing a lead stays on the page
The lead file put seven AI paragraphs in the same flow as "Mobile", each with
the weight the audit trail gives it, so the card was mostly quotations with the
facts lost between them — and the same prose sat in the trail two feet to the
right. Facts group at the top now; the reasoning is a caption, two lines and a
link to the dialog that already held it. Pairs go side by side rather than
stacked, and the CSS columns become a grid: column flow read top-to-bottom-then-
across, so groups landed in an order nobody could predict and tore raggedly
between 2-field and 12-field groups.

New lead was a route. Pressing it cost you your place in the queue you were
working through and left you three navigations away. It is a dialog over the
page now, with the same modal furniture as the conversation and reasoning ones.

Three of its boxes were answered by the system before anyone saw them — the
channel, the partner code and the submitting agent, all stamped by prefill from
the signed-in identity. They are hidden at RENDER only: `fields` still holds
them, so they validate and submit exactly as before. Filtering them out of
`fields` would have dropped the attribution and 400'd on the mandatory channel.
A stamped field that comes back empty is drawn anyway, so a prefill that does
not resolve cannot fail validation against a box that is not on screen.

Business Name is scoped to SME. An INIT form has no lead to filter on, so
visibility reads the live answer to Product Line first and the saved lead
second — the form narrows as it is filled, and a motor renewal drops from
twelve boxes to nine. It is optional, so hiding it cannot block a submission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:53:16 +05:30
3069bf4849 console: the lead file is a file again, and the journey is on the page
THE FILE WAS THIRTEEN TABS. One group of fields visible at a time, so
"what do we know about this lead?" meant thirteen clicks and a good
memory — on a page called the Lead FILE. A file you can read one page of
at a time is a filing cabinet.

Every group is now a card, laid out in columns that reflow to the width:
three on a wide screen, two normally, one on a laptop. The whole 360 is
one scroll and Ctrl-F finds anything. Groups avoid breaking across a
column boundary — a heading at the foot of one column with its fields at
the head of the next is worse than an uneven column — and inside a group
the label/value pairs stack, so a long value is not squeezed into half a
column against its label.

THE JOURNEY IS AT THE TOP. The page named the current stage in a corner
chip and said nothing about the path, so "how far along is this?" — the
first question anyone asks — needed the workflow memorised.

Passed steps are read from the AUDIT, not assumed from the order. A lead
can skip (documents uploaded from Qualified carried one straight past
Contacted) and can go backwards (a callback returns it to Awaiting
Contact); colouring everything left of the current stage would claim
steps that never happened. The side states — Parked, Referred, Lost,
Declined — are deliberately off the rail: they are departures from the
path, not points every lead passes, and a row implying otherwise would be
a lie about the process. When the lead is in one, the rail names it at
the end instead.

THE TRAIL STOPS READING AS A MARGIN NOTE. 384px beside a full-width file
made a column of two-line entries look like an aside; it is the half of
the page that says what happened. Widened to 440.

Layout only. The form, the actions, the audit fetch and every submission
path are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:37:32 +05:30
de8b158ca9 console: the nav navigates, and the screens act
"New lead" sat in the sidebar. A sidebar is for moving between places;
filing a lead is an action, and an action in a list of destinations is
the one item that does not behave like its neighbours — everything above
and below it changes what you are looking at, and it did something.

It now sits on the overview and on every queue, beside the lists it adds
to, which is also where somebody is standing when they realise they have
a lead to file. Same role gate as before: shown only to a user who holds
an entry door.

The sidebar's own button CSS went with it rather than being left for
someone to wonder about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:31:07 +05:30
33bf65c5f5 console: All leads means all leads
The page called "All leads" dropped every closed lead from its query, so
a lead that was lost, declined or onboarded vanished from the one place
somebody goes when they cannot find something. That is the page whose
entire job is to answer "where did it go?".

Closed leads are shown now, dimmed, with the stage they ended in. A
"Hide N closed" toggle is offered only when there is something to hide
and is OFF by default — hiding is a choice, not the behaviour.

The filter also moved off the query and onto the render. A view that
throws rows away when it fetches them cannot be un-filtered by a toggle,
and the header can now say how many are closed rather than silently
showing fewer than the count claims.

Dimmed rather than struck through or greyed to unreadability, and hover
restores it: the row is muted, not disabled — it still opens, and the
lead file behind a lost lead is often exactly what someone wants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:38:46 +05:30
4b590474e4 console: the call, in the customer's own words
Recording is now on for Meera (93) and the transcript is kept on the lead
(94), so the call stops being a thing only one AI ever saw.

The wake that ends a call already carried far more than Engage used — a
summary, a sentiment analysis, and the FULL TRANSCRIPT, turn by turn —
and all of it was dropped once the three-sentence write-up was done. So a
customer said "I think it's 12,000 rupees" and "lower premium", and the
file held a paraphrase.

That matters here more than it would elsewhere: consent_artefact =
verbal_call is written from what the agent HEARD, and it is the artefact
justifying every later contact. The evidence for it should be the
customer's own words.

"Hear the call" now sits on the Log Contact entry and opens the
conversation, laid out like the WhatsApp thread — agent right, caller left
— because it is the same kind of thing and should not need learning
twice. It also files under Contact in the lead record.

Turns are split on the speaker labels rather than on newlines: a single
turn wraps, and splitting per line shattered one sentence into four
bubbles. A transcript that will not parse is shown raw — it is still
evidence, and "nothing to display" would be a lie.

NOT INCLUDED: the audio. Recording is enabled and the WAV is uploaded,
but recording_url is delivered to the agent's post-call WEBHOOK
(voice_internal_handler.go:429), not in the wake payload, so nothing puts
it on the lead. Wiring that webhook to an external-API activity is a
separate change, worth doing when someone asks to listen rather than to
read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:19:53 +05:30
c7cd5602ae console: every step shows what it actually decided
The trail rendered twelve fields as prose and four as money. The other
hundred-odd were invisible — so Capture Motor Risk, which writes engine
capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
add-ons, appeared as a heading and a timestamp. The only way to see what
an agent had decided was to open the Lead file and guess which values
came from which step.

Each entry now carries a folded "N fields written" list: every field that
step committed, by label, with the values already shown above it
(narrative, money, documents) excluded so nothing repeats. Folded because
most steps write a dozen and the trail has to stay readable as a story.

WHAT THIS IS NOT, and it matters: it is not the model's internal
reasoning, and it is not its tool calls. Both ARE recorded — ai_reasoning,
ai_confidence, ai_model and ai_tool_calls sit on the same audit row — but
GetAuditLogsForApp selects eight columns and none of them are those, so
an app JWT cannot read them at all. Surfacing them needs four columns
added to that query and to ActivityLogEntry, which is a view-service
change and an image build.

This is the next best thing and arguably the more useful half: not what
the agent thought, but what it committed, itemised against the step that
committed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:12:51 +05:30
cb94e2417f console: Quote Presented is two situations, not one
The page said "Waiting for the customer to reply on WhatsApp" directly
above a panel saying "Accepted by the customer, over WhatsApp. KYC and
underwriting are running." Both rendered from the same instant; only one
was true.

Quote Presented has the same shape as Document Pending — the state holds
still while the situation underneath it changes completely. Before the
customer answers, the lead is genuinely with them and nothing is running.
After they accept, KYC and the underwriting screen run inside the SAME
state, because the lead does not move until underwriting clears. phaseOf
splits it, so the strip now says "Verifying KYC" and then "Screening the
risk for underwriting", and the Next step line records only the fact of
the acceptance rather than repeating what is running.

That also puts this stretch under the stall detector for the first time,
which matters: the KYC agent talked itself out of the underwriting screen
earlier today and the lead sat in Quote Presented with a passed KYC and
nothing happening. With this it would have said so after five minutes and
offered "Run the underwriting screen" — both steps ops_admin can perform,
so the recovery needs nobody's help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:48:25 +05:30
93baba3fa1 console: when an agent stops, say so and offer the way back
The lead page told an operator an automated step "typically completes
within two minutes; this view refreshes automatically" — and went on
saying it indefinitely. Today a lead sat forty minutes under that
sentence while the model provider returned 502s.

Agents stall, and for reasons this app does not control: the provider
slows from 6s a call to 90s or drops the request, a thinking budget runs
out mid-sentence, a late webhook wakes the wrong employee. From the
console every one of those looks identical — a lead that stops — so the
screen now reports the only thing it can honestly know (nothing has
happened for N minutes), says the work so far is safe, and offers the
way out.

THE WAY OUT IS NOT A RETRY BUTTON, because the platform has none and no
agent can be woken directly. But every agent is woken BY AN ACTIVITY, so
performing that activity again wakes it again. nudgeFor() holds that
mapping, and inside Document Pending it picks by how far the chain
actually got — three agents work that state in sequence and the one to
restart is the one that did not finish.

That matters most for the Advisor. It is the only AI step nobody may
perform by hand — permitted to ai_advisor alone — so re-performing the
activity that wakes it is the ONLY route back, and it is the step that
failed twice today. Without this table an operator's only option was to
wait or to call me.

Five minutes before it says anything: an employee wake plus a slow model
is legitimately three or four minutes, and a console that cries stall on
a working lead is one nobody reads.

The clock lives in state, ticking every 20s, rather than Date.now() in
the render body — reading the wall clock while rendering is impure, and
the counter would otherwise only move when something else happened to
re-render the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:37:25 +05:30
5d146e9ffb console: a thread that shows what the customer actually received
The order was right — the audit rows are in sequence and the panel
rendered them in sequence. Two other things were wrong.

A MESSAGE THE CUSTOMER GOT WAS NOT IN THE THREAD. After "Okay I confirm
the policy go ahead" the panel showed nothing from us until their next
question, so it read as though we had ignored somebody agreeing to buy.
We had not — the confirmation went out immediately — but it was composed
inside a trigger node and never written to a field, and the console can
only show what the workflow records. 90 stores it in customer_answer, so
it appears here and in the trail. An operator reviewing an acceptance no
human took needs to see exactly what the customer was told at the moment
they said yes.

ORDERING IS NOW (timestamp, id). One submission writes three rows in the
same second — the trigger commit, its repeat, the settle — so a timestamp
alone left their order to the sort's stability, and a reply could print
before the message it answered whenever the two landed in the same
second. It had not happened yet; it was waiting to.

DE-DUPLICATION ONLY COLLAPSES AN IMMEDIATE REPEAT, not any repeat
anywhere in the thread. A customer who asks the same thing twice because
the first went unanswered has said it twice, and a thread that silently
showed it once would hide exactly the impatience an operator needs to
see. Only a same-side, same-words turn directly after its twin is the
platform talking to itself.

The footer says which sends are still absent and why: the quote (a
registered template, not text we compose) and the read receipt (which
would sit between every question and its answer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:47:49 +05:30
f4c67310cb console: the agents explain themselves, and the chat reads as a chat
TOOLS ARE VISIBLE NOW. Clicking any agent — in the trail or the "Worked
by" strip — opens a card saying what it is, when it runs, what it can
reach and what it reads. The answer is short and it is the reassuring
kind: four of the five hold no tools at all and reason from the policy
wordings, and the only tool in the roster reads a partner registry.

That answer stopped being true once, silently. Intake held a tool that
could telephone customers, its charter never mentioned it, and it used it
on every lead believing it was a duplicate check (89). Nobody could see
that from any screen. This is the screen that would have shown it — which
is the actual argument for building it, beyond a demo looking better.

Where an agent has no tools the card says so in words rather than showing
an empty section. "No tools" is the single most reassuring fact about
something that writes into an insurance file; a blank reads as missing
data.

The roster is a hand-maintained mirror of the employee config, like
STAGES and ACTIONS, and carries the same hazard: a tool added there and
not here is described wrongly, silently. Named in the file.

THE CONVERSATION WAS EATING THE TRAIL. Every WhatsApp turn was its own
entry — heading, actor, quote box — so a four-message exchange occupied
more of the trail than the entire underwriting chain, and a resend
printed the same sentence four times because the customer sent it four
times. A contiguous run now collapses into ONE entry: a WhatsApp mark, a
count, the latest line, and a link that opens the thread. A second
exchange later in the lead stays separate, because that is a different
episode in the story.

And the actor is right. The channel performs these as the system, so the
trail read "System · Customer Reply" — the opposite of what happened.
It says "the customer" now, in the channel's own green, and the rail dot
takes that colour so a scan shows where the conversation was without
reading a word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:41:19 +05:30
171aa4c2fa console: a live trail, faces for the agents, and the thread as a thread
Five of the six asks. The sixth — the whole row as the click target —
shipped in 704afe1 and is already live on both grids.

THE AUDIT TRAIL WAS FROZEN. Timeline fetched the rows once on mount and
never again, so a lead being worked by five agents in three minutes
showed the trail as it was when the page opened — on the one screen whose
job is watching work happen. The rows move to the lead page, which
already polls every 12s, and Timeline becomes presentational. One fetch,
one poll, and the conversation view reads the same rows so the two cannot
disagree.

THE ACCEPTANCE BUTTON OUTLIVED THE ACCEPTANCE. Recording an acceptance
twice is not a loop, a recovery or an alternative — it is meaningless,
and offering it invites someone to overwrite a customer's WhatsApp
acceptance with a worse record of the same event. Gone once
acceptance_ref is set, replaced by a line saying who accepted and when.

THE AGENTS HAD NO FACE. Five of them carry this workflow and the console
named them three different ways — "ai_engage" here, "Engage" there,
"Engage AI" elsewhere — so nobody could see that the thing which called
the customer and the thing which wrote the quote were one worker. One
roster now (api/agents.js): a short name, a colour and two initials each,
used wherever an agent is named. People get a disc too, in grey — a trail
where the machines are decorated and the humans are plain text reads as
though the machines are the important ones, which is backwards on a
screen built for oversight. A "Worked by" strip above the trail shows the
team at a glance.

THE CONVERSATION WAS A LOG, NOT A THREAD. Each turn sat as its own entry
among twenty others. Now one button opens it as a thread — theirs left,
ours right, oldest first — and the footer says plainly which parts it
holds: the quote, the read receipt and the acceptance confirmation are
sent by trigger nodes and never written to a field, so they are not
there. A thread that quietly omitted them would be worse than one that
admits what it is.

CONFIRMING A PREMIUM IS A SIGN-OFF, NOT A TASK. It is the only step
locked to one role and the only one that touches money — an employee that
could mark a premium received could put a customer on risk for a policy
nobody paid for. It sat fourth in a list of six queues. It gets its own
band now, shown only to the role that owns it, amber only when something
is actually waiting: a permanent alert colour on an empty queue teaches
people to stop seeing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:29:02 +05:30
8eb80dc371 console: the payment chase is the scheduler's, not a button
86 moved Log Payment Nudge onto a timer — booked 24h out by Request
Premium, re-booked at 48h to a cap of three — because it was firing 47
seconds after the request and recording an outcome for a conversation
nobody had had.

A scheduled activity has to carry ZERO roles or the scheduler's own
perform fails silently, so "Chase the payment" would now 403 for
everyone who pressed it. It stops being offered.

A person who actually wants to reach the customer has Reply to the
customer, which sends a WhatsApp rather than recording that one was due.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 14:59:12 +05:30
927d335280 console: show the WhatsApp exchange, and let ops answer through it
Engage can now answer a customer's question from the policy wordings
(82), so the lead file has a conversation in it rather than a single
recorded reply.

The audit trail renders both halves — "The customer said", then "We
replied" — so the exchange reads as one, which is what an operator
reviewing an acceptance nobody in the office made actually needs.

The lead file gets a Conversation group beside Proposal: the question,
the answer, and the count. Acceptance stays in Proposal, one tab away
from the words that produced it.

"Reply to the customer" is offered to ops_admin only. Engage declines
anything it cannot answer from the wordings, anything that is a
negotiation, and everything past three answers — so a person needs a way
to take over, and it has to be through the same thread or the reply
lands somewhere the lead file never sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 13:32:12 +05:30
e5b413ecac console: the customer accepts from their own phone
Recording the acceptance was the third human touchpoint, and it was a
person in the office typing what a customer had said somewhere else.
The customer's WhatsApp reply now drives it (81), so this stage has no
step of its own.

"Record the acceptance" drops from a step to a folded recovery lever —
kept, because a customer who says yes on a CALL still needs somebody to
record it, and because a reply Engage judged ambiguous has to be
actionable by a person.

That left Quote Presented with an empty screen at exactly the stage an
operator is most likely to wonder whether something has broken, so the
waiting state now says so. Grey, and no pulse: nothing is happening, and
that is the correct state — unlike the AI strip, where a still dot would
mean something is wrong.

The reply itself surfaces in two places: the audit trail carries it as
"The customer said", and the lead file files it under Proposal beside the
acceptance it produced. The evidence and the decision it justifies are
one line apart, which matters when nobody in the office made it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 13:26:46 +05:30
5ff74ea22d console: numbers in the navigation, and reasoning behind one click
THE SIDEBAR NOW CARRIES COUNTS. It deliberately did not, on the argument
that a tally could only come from a second full list call that would then
disagree with the queue's own total. Right about the cost, wrong about
the conclusion: with no numbers the only way to learn whether anything
was waiting on you was to open all nine queues in turn, which is the
question navigation exists to answer.

There is no second call. The overview's existing fetch moved into a
PortfolioProvider that both surfaces read, so this is one call fewer than
before and the two agree by construction. Bounded at 200 rows, as the
overview always was; past that the honest answer is an aggregate
endpoint, not a bigger limit.

What the badge counts is what needs a PERSON — phaseOf again, so a lead
whose documents are in and whose AI chain is running is reported beside
the badge rather than inside it. An amber dot marks a queue holding a
renewal inside a week, which is the only reason to open one queue before
another and was previously invisible. Zero is shown rather than hidden:
"nothing here" is an answer, and a queue that disappears when it empties
makes the sidebar move under the cursor.

Closed is folded into a summary. Three of the twelve queues, opened about
once a week, and at equal weight they made the live ones harder to find.

REASONING OPENS IN A DIALOG, NOT INLINE. The audit rail is 320px wide
with one entry per step, and a 1,500-character rationale expanding in
place pushed a lead's whole history off screen to read one sentence of
it. Nobody reads a paragraph in a sidebar. The finding — the AI's own
first sentence, which is already the conclusion — stays on the line; the
working is one click away and one Escape back, headed by what is being
read ("Call — Log Contact") rather than by nothing. Escape closes, the
page behind does not scroll, and focus returns to the button that opened
it so a keyboard reader keeps their place.

Two headings that described the container rather than the contents:
Record → "Lead file", and the audit trail's subtitle now says what a
reader gets from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 12:42:40 +05:30
d6756b4d2e console: show what the workflow actually did, and when it stopped
Traced the console against the workflow it renders. Five gaps, all of
which made a working chain look like a broken one.

THE AUDIT TRAIL WAS READING THE WRONG KEYS. An audit row's data is keyed
by the ACTIVITY's field ids, which carry a per-form suffix — the call
notes arrive as contact_notes_2, the document request as
documents_notes_3 — and the timeline matched the unsuffixed global ids.
Almost nothing ever matched, so the trail was a list of activity names
with the agents' reasoning invisible behind it. The one narrative line
that did appear was an accident. It now reads the platform's own typed
`fields[]` (label, data_type, value) and matches on the base id.

That is also what lets it answer the question that was asked: an upload
now renders as "3 documents received" with each one named and openable,
instead of a bare "Collect Documents".

DATA_UPDATE ROWS WERE CLASSED AS AGENT WORK. A bookkeeping row inherits
the roles of whoever caused it, and the AI check ran first — so an AI's
field write appeared in the trail as an entry titled "Data updated",
while the toggle underneath still offered to reveal the others. The
activity id says a row is bookkeeping; the roles say who triggered it.

DOCUMENT PENDING IS TWO SITUATIONS. Before the upload a person has to
act; after it the lead stays in the same state while three AI steps run.
Both rendered as "waiting on the partner agent", so a lead that had just
been served showed an Upload documents button under a panel saying the
documents had been received, and counted against the Action-required
queue. phaseOf() derives the difference once, from documents_status, and
the header, the queue count, the row status and the action list all read
it. The upload demotes to recovery — "Replace or add a document", folded
away with the other levers.

A STOPPED CHAIN LOOKED IDENTICAL TO A RUNNING ONE. The rating engine
refuses to price without an IDV and writes so into quoted_breakup;
Engage then declines to raise a quote it would have to fabricate. Both
are right, and nobody was told: the refusal sat in a field on a tab and
the lead never moved again. blockedOn() surfaces it as an amber strip
that names the missing value and opens the form that carries it.

THE STALL MEASURE WAS DATED FROM THE WRONG COLUMN. progress() fell back
to created_at when updated_at was absent — which it always was, because
neither view returned it — so every lead older than half an hour would
have reported stalled. It reads updated_at only; the view supplies it as
of 76.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 12:17:52 +05:30
704afe170e console: the row is the control, and the agent says what it is doing
Two changes to the queue and overview tables.

The row is the click target. An "Open" button was a 90px destination inside
a 1000px row that already reads as one object, and it put a second tab stop
on every line. The row now carries the click, Enter/Space, a focus ring and
the hover tint; the button column is gone, along with its CSS.

The column it freed shows what is actually happening. This workflow is run
by agents and watched by a person, so the question a queue has to answer is
not "which stage" — inside a queue every row shares it — but "is it moving".
An automated stage now reports working (under 3 minutes), waiting (under 30),
or stalled with the elapsed time, with a pulse on the live one and a red left
edge on the stuck one. Thresholds are generous on purpose: an employee wake
takes a minute or two and a scheduled retry can be hours out, so stalled
means "longer than any normal step", not "longer than average".

Before this, a lead three hours into "Calling the customer…" looked identical
to one thirty seconds in — which is the failure this console exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 11:52:20 +05:30
8b6a387cf6 form: stop asking for what the workflow computes
`documents_status` was a dropdown — not yet / partially / all uploaded —
put to the person who had just attached the files. It asked them to tell
the system what it could see for itself.

Not cosmetic. That field gates the AND-join that wakes Engage after an
upload (55). Left on its default, the lead sits in Document Pending with
all three documents attached and nothing happening, and it reads as the
AI having stalled rather than as a form field nobody filled.

It is derived in the trigger now (75), from the files themselves, after
the commit and before the join reads it — server-side, because a value
computed only in the browser would be right on screen and absent to the
API. `documents_notes` goes with it: the same script writes what is
still missing, and a person overwriting that would be arguing with the
file list.

So both are declared derived and no form asks for them, with or without
a lead behind it. Matched on the base id, so every per-activity suffix
is covered rather than the two that happen to exist today.
2026-09-08 11:43:52 +05:30
fc5eace791 form: make OCR autofill actually reach the fields
Uploading an RC read the document correctly, said so, and filled
nothing.

The endpoint answers keyed by the ocr_config's extraction_fields[].key
— `reg_no`, `make_model`, `mfg_year`, `engine_cc`, `fuel` — because
that is what the vision prompt was asked to produce. Its own package
doc says so. The form's fields are `motor_reg_no_6`, `motor_cc_3`,
`motor_fuel_3`. The console wrote the response straight into values, so
every extracted value landed on a key no field renders: the read
succeeded, the "we read it for you" panel appeared, and the boxes
stayed empty.

This is the third place the same mismatch has bitten. Prefill hit it
(the pipeline names globals, the form uses suffixed activity keys) and
the validation errors hit it (the workflow reports the machine key).
Different surfaces, one cause: nothing in the stack translates between
a global field name and a form's per-activity id, so every consumer has
to do it and each one forgot.

The bridge was already on the field. ocr_config.field_mappings maps
extraction_key -> target_field (the global), and the form field is that
global plus a suffix. Verified against the live Collect Documents
schema: all five RC keys now resolve to real fields.

Also: which value wins. Seeding the form from the lead record (6a6801d)
made every mirrored field non-empty, so the old "only fill what is
blank" rule would have blocked OCR from writing anything at all. Values
copied off the lead are now tracked, and a document may overwrite them —
it is the better source for what it states. Anything typed by a person
is never overwritten, and stops being overwritable the moment it is
typed.
2026-09-08 11:06:41 +05:30
6a6801d2bd console: all-leads view, document form prefill, and two things that should not be offered
Four operator-reported issues, one commit because two of them are about
the same form.

ALL LEADS WAS GONE. The team's rewrite of the queue screen dropped the
/stage/all view, so finding a lead meant guessing which of nine queues
it sat in. Restored: no stage filter, closed leads dropped client-side,
one request for the whole open book. Linked under Overview in the
sidebar so it is one click from anywhere.

THE DOCUMENT FORM ASKED FOR WHAT THE LEAD ALREADY KNEW. Collect
Documents mirrors fourteen record fields — registration, make and model,
previous insurer, expiry, policy number, PAN — so the OCR has somewhere
to write. Rendered blank, they read as fourteen more things to type, on
a form whose whole point is uploading three files. They are now seeded
from the lead by base key: an agent uploading for KA01MF6618 sees
KA01MF6618 already there. Server prefill wins, then anything typed;
files and generated ids are never seeded, and an INIT form has no
record to seed from. SME-only fields were already hidden on a motor
lead by fieldLine — that half was working.

"SEND A REMINDER" IS THE SCHEDULER'S JOB, NOT A BUTTON. It fires at
24h, 72h and 120h on its own. Offering it as a loop action invited a
person to send a reminder the platform was already about to send. It
and Retry Call move to `force` — reachable behind "Lead not moving?"
for the day the schedule itself fails, and otherwise out of sight.

THE UPLOAD STAYED THE "NEXT STEP" AFTER THE UPLOAD. Once
documents_status is complete the lead remains in Document Pending while
Engage captures the risk and the quote is built — and the panel kept
saying "Upload documents", which reads as though nothing was received.
Now, when complete, the upload becomes a loop (a fourth document, a
correction) and the panel says what is actually happening: documents
received, Engage AI is capturing the risk and preparing the quote.

Permissions unchanged; ACTIONS still matches
tbl_wf_state_allowed_activities exactly.
2026-09-07 19:26:13 +05:30
07d4608888 lead: show the next step, not the permission list
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.
2026-09-07 17:55:51 +05:30
2d9c072572 lead: one entry per submission, and the finding above the fold
Two things on the lead page.

THE AUDIT TRAIL REPEATED ITSELF. "Qualify Lead" appeared three times in
a row and "Partner Agent Lead" twice, which reads as the AI having done
the same thing three times. It had not. One submission writes several
rows, told apart by execution_state:

  zk-act-qualify   TRIGGER_PERFORMED     the trigger's commit
  zk-act-qualify   TRIGGER_PERFORMED     again, on the settle path
  zk-act-qualify   zk-state-qualified    the one that moved the lead

Only the last means anything to somebody reading the file. They are now
collapsed into one entry carrying the earliest timestamp — when the
operator acted — and whichever state and payload is populated.

Matched on (same activity, within fifteen seconds) rather than on
execution_state, so a genuine repeat survives: Collect Documents really
is performed twice on a lead whose first upload was short, and those are
minutes apart. And matched by looking BACK through recent entries rather
than at the previous one, because the platform interleaves a DATA_UPDATE
row between the two halves of a submission — the rows to merge are near
each other in time but not adjacent in the list.

THE AI PARAGRAPHS HAD NO SUMMARY. attribution_reason and
eligibility_reason run to a paragraph each and opened folded, so the
finding could not be read without expanding. ClampText now lifts the
first sentence out as a headline and puts the rest behind "Show the
reasoning" — no new field, and nothing invented: the employees already
write a conclusion and then its evidence.

It falls back to plain folding when the split would be useless — no
sentence terminator, a first sentence that is the whole paragraph, or
one short enough to be a fragment rather than a finding.

The other half of that is in the charter (70_verdict_first.sql). The
headline is only as good as the sentence, and Intake was opening with
"Tool 29601 confirmed..." — an internal id — because it had been told to
"name the evidence, not the conclusion". Right about content, wrong about
order: it now leads with what it decided and gives the evidence second.
2026-09-07 17:42:36 +05:30
4e5df66389 form: say what is missing, in the field's own name
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.
2026-09-07 16:57:20 +05:30
dbbd34f7bf form: apply the prefill the server already sends
"Sourced By", "Submitted By" and "Source Channel" rendered empty on the
new-lead form. The prefill pipeline was working the whole time — the
form-screen response carries

  partner_code    "Shetty Insurance Services (POSP-77341)"
  rm_or_agent_id  "arjun.posp@zurichkotak.example"
  source_channel  "agency"

and the form ignored all three.

The two sides key the same field differently and nothing reconciles it.
A form field is the ACTIVITY key, suffixed because one global may be
used on several activities in a version — partner_code_3,
rm_or_agent_id_3, source_channel_4. A fieldMapping node names the
GLOBAL — partner_code, rm_or_agent_id, source_channel. Neither is
wrong. The seeding loop matched on f.id exactly, found nothing, and
seeded nothing, with no error anywhere.

Now it tries the exact id, then the field uid, then the global name with
the numeric suffix stripped. Taking all three means this survives a
pipeline authored in either convention rather than breaking again the
next time one is written the other way. Empty and null are skipped so a
mapping that resolved to nothing does not overwrite a real default.

Worth noting the failure mode rather than just the fix: a prefill that
matches nothing looks exactly like a prefill that was never configured.
It is why the pipeline read as "empty" from the config tables — the
authoritative store is tbl_wf_activity_prefill_nodes, not the
pipeline_config JSONB, and the quickest honest check is neither: POST
/view/form-screens and read prefill_data.
2026-09-07 16:45:45 +05:30