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>
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>
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.
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.
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.
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.
"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.
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.
/ocr-extract answers { extracted: {...}, raw: "..." }. The client checked
`fields` first and `extracted` second, which was right by luck — but an
extraction that returned nothing, or threw, did so silently: the button
went back to idle and the operator had no way to tell a read from a
no-read.
Now an empty or failed extraction says so under the field, and either
way the UPLOAD survives. The file is already stored and referenced; a
failed read only means the fields are not pre-filled, which someone can
recover by typing. Losing the upload because the read failed would not
be recoverable.
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 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>
lead_ref is now an id_gen, issued server-side and stripped from the
submission — so the form shows it as auto rather than as an empty box
somebody is expected to type a reference code into, and never sends it.
Also seeds the inputs from the prefill the form response carries. The
door's channel and the signed-in agent are stamped by a prefill pipeline,
so the form should show them already filled rather than ask.
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>