feat: chain-of-custody trace — every step, attributed and expandable

The AI trail tab listed who did what but nothing about WHAT each step did, and
the agent's contribution was the one step with no visible action behind it.

New InstanceTrace: one chronological list of every completed step on a file,
read from the workflow's own audit trail.

  * Each row names the actor and role, with a bot glyph for the agent and a
    person for a human, plus a header count — "1 by an agent, 3 by people".
  * ELAPSED TIME between steps, and this is the point: humans act seconds
    apart because someone is clicking, and the agent's row shows a minute and
    a half of unattended work. That number is the demo.
  * Expand a row to see exactly what was submitted — labelled, typed, from
    view-service, so a human's remarks and the agent's analysis are inspected
    the same way.
  * Expanding the AGENT's row also shows the three checks it ran before
    writing, each as asked/returned, with the breached one in amber.

Removed the standalone "tools the assessor calls" card: the same information
now sits attached to the step where it happened, which is where a reader looks
for it, and showing it twice was clutter.

Still not shown, still not invented: individual model calls, the SQL each tool
ran, tokens and cost. That is in aiemployee.tbl_ai_trace_events and needs
either an org-scoped token or an rdbms bridge. Tool RESULTS are shown and
attributed; raw payloads are not fabricated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-08-18 11:25:52 +05:30
parent 8a3ec901b6
commit c0059194e4
3 changed files with 266 additions and 197 deletions

View File

@ -73,6 +73,9 @@ export interface AuditEntry {
state_name?: string; state_name?: string;
created_at?: string; created_at?: string;
performed_at?: string; performed_at?: string;
/** What was submitted, already labelled and typed by view-service. This is
* what lets the trace show WHAT each step did, not just who did it. */
fields?: Array<{ field_id: string; label: string; data_type: string; value: string }>;
ai_reasoning?: string | null; ai_reasoning?: string | null;
ai_confidence?: number | null; ai_confidence?: number | null;
ai_model?: string | null; ai_model?: string | null;

View File

@ -1,8 +1,8 @@
import { Bot, Calculator, CheckCircle2, Cpu, ScrollText, User } from 'lucide-react'; import { CheckCircle2, Cpu } from 'lucide-react';
import { Badge, Card, Facts } from './core'; import { Badge, Facts } from './core';
import { InstanceTrace } from './InstanceTrace';
import type { AuditEntry, Row } from '../api/types'; import type { AuditEntry, Row } from '../api/types';
import { POLICY } from '../api/config'; import { str } from '../format';
import { dateTime, money, moneyShort, num, pct, ratio, str } from '../format';
/** /**
* Who did what on this file, and what the agent actually saw. * Who did what on this file, and what the agent actually saw.
@ -36,171 +36,14 @@ import { dateTime, money, moneyShort, num, pct, ratio, str } from '../format';
* whole job is showing what actually happened. * whole job is showing what actually happened.
*/ */
/** The roles that belong to an agent rather than a person. */
const AI_ROLES = ['ai_credit_assessor', 'ai_loan_advisor'];
function isAgent(e: AuditEntry): boolean {
const roles = e.user_roles ?? [];
return roles.some((r) => AI_ROLES.includes(r));
}
/** Audit rows worth showing a human. The commit row for an activity carries the
* state it reached; the rest is engine bookkeeping. */
function meaningful(entries: AuditEntry[]): AuditEntry[] {
return entries
.filter((e) => {
const st = str(e.execution_state);
// A commit row's execution_state is the state uid it landed on.
return st.startsWith('hdfc-state-');
})
.slice()
.reverse();
}
export function AiDecisionPanel({ row, audit }: { row: Row; audit: AuditEntry[] }) { export function AiDecisionPanel({ row, audit }: { row: Row; audit: AuditEntry[] }) {
const steps = meaningful(audit);
const agentSteps = steps.filter(isAgent);
const itr = num(row.itr_declared_income);
const gst = num(row.gst_turnover_12m);
const bank = num(row.bank_credits_12m);
const variance = num(row.income_variance_pct);
const dscr = num(row.dscr);
const maxElig = num(row.max_eligible_amount);
const analysis = str(row.ai_analysis); const analysis = str(row.ai_analysis);
const citations = str(row.assessment_citations); const citations = str(row.assessment_citations);
const deviation = str(row.deviation_flags); const deviation = str(row.deviation_flags);
const isPersonal = str(row.loan_product_family) === 'personal';
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* ---------------- who did what ---------------- */} <InstanceTrace row={row} audit={audit} />
<Card
title={
<span className="inline-flex items-center gap-2">
<ScrollText className="h-4 w-4 text-muted" />
Who did what
</span>
}
subtitle="From the workflow's audit trail — not from this screen's opinion"
actions={
agentSteps.length > 0 ? (
<Badge tone="violet">
{agentSteps.length} of {steps.length} by an agent
</Badge>
) : (
<Badge tone="slate">no agent steps yet</Badge>
)
}
>
{steps.length === 0 ? (
<p className="px-4 py-4 text-sm text-faint">No completed steps recorded yet.</p>
) : (
<ol className="px-4 py-2">
{steps.map((e, i) => {
const agent = isAgent(e);
return (
<li
key={`${e.id}-${i}`}
className="flex items-start gap-3 border-b border-line py-3 last:border-0"
>
<span
className={`mt-0.5 shrink-0 rounded-full p-1.5 ring-1 ring-inset ${
agent
? 'bg-ai-soft text-ai ring-ai-edge'
: 'bg-panel2 text-muted ring-line'
}`}
>
{agent ? <Bot className="h-3.5 w-3.5" /> : <User className="h-3.5 w-3.5" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-ink">
{str(e.activity_name) || str(e.activity_id)}
</span>
{agent && <Badge tone="violet">agent</Badge>}
</div>
<div className="mt-0.5 text-xs text-muted">
{str(e.user_name) || 'System'}
{(e.user_roles ?? []).length > 0 && (
<span className="text-faint">
{' · '}
{(e.user_roles ?? []).join(', ')}
</span>
)}
</div>
</div>
<span className="shrink-0 text-xs text-faint">{dateTime(e.created_at)}</span>
</li>
);
})}
</ol>
)}
</Card>
{/* ---------------- the tools ----------------
Deterministic SQL the assessor calls instead of doing arithmetic. The
figures are the ones on the file; the attribution is the fixed mapping
from 09_agent_tools.sql. */}
<Card
title={
<span className="inline-flex items-center gap-2">
<Calculator className="h-4 w-4 text-muted" />
Tools the assessor calls
</span>
}
subtitle={`Deterministic SQL, same formulas as the gate · policy ${POLICY.version}`}
>
<div className="space-y-3 px-4 py-4">
<ToolBlock
name="hdfc_income_crosscheck"
purpose="Compares the three turnover sources and returns the spread"
rows={[
{ label: 'Declared to tax (ITR)', value: moneyShort(itr) },
{ label: 'Declared to GST', value: moneyShort(gst) },
{ label: 'Observed in banking', value: moneyShort(bank) },
{
label: 'Spread',
value: `${pct(variance)} against ${POLICY.varianceTolerancePct}% tolerance`,
tone:
variance === null
? undefined
: variance > POLICY.varianceTolerancePct
? ('amber' as const)
: ('emerald' as const),
},
]}
/>
<ToolBlock
name="hdfc_capacity_check"
purpose="EMI, coverage ratio and the ceiling the file can carry"
rows={
isPersonal
? [
{ label: 'FOIR', value: `${ratio((num(row.foir) ?? 0) * 100, 1)}% against ${(POLICY.foirCeiling * 100).toFixed(0)}% ceiling` },
{ label: 'Max eligible', value: `${moneyShort(maxElig)} against ${moneyShort(row.requested_amount)} requested` },
]
: [
{ label: 'DSCR', value: `${ratio(dscr)} against ${POLICY.dscrFloor} floor` },
{ label: 'Max eligible', value: `${moneyShort(maxElig)} against ${moneyShort(row.requested_amount)} requested` },
{ label: 'Indicative EMI', value: money(row.offered_emi ?? null) },
]
}
/>
<ToolBlock
name="hdfc_standing_and_conduct"
purpose="Bureau standing and account conduct against each ceiling"
rows={[
{ label: 'Bureau score', value: `${str(row.bureau_score) || '—'} against floor ${POLICY.bureauFloor}` },
...(isPersonal
? []
: [{ label: 'CIBIL MSME Rank', value: `CMR-${str(row.cmr_rank) || '—'} against ceiling CMR-${POLICY.cmrCeiling}` }]),
{ label: 'Live DPD', value: `${str(row.live_dpd_max) || '—'} d against ${POLICY.dpdCeiling} d ceiling` },
{ label: 'Cheque returns', value: `${str(row.bounce_count) || '0'} against ${POLICY.bounceCeiling} threshold` },
]}
/>
</div>
</Card>
{/* ---------------- what the agent wrote ---------------- */} {/* ---------------- what the agent wrote ---------------- */}
{analysis && ( {analysis && (
@ -237,38 +80,3 @@ export function AiDecisionPanel({ row, audit }: { row: Row; audit: AuditEntry[]
</div> </div>
); );
} }
function ToolBlock({
name,
purpose,
rows,
}: {
name: string;
purpose: string;
rows: Array<{ label: string; value: string; tone?: 'amber' | 'emerald' }>;
}) {
return (
<div className="rounded-md border border-line bg-panel2 px-3 py-2.5">
<div className="flex flex-wrap items-baseline gap-2">
<code className="rounded bg-panel px-1.5 py-0.5 text-[11px] font-semibold text-brand ring-1 ring-inset ring-line">
{name}
</code>
<span className="text-xs text-muted">{purpose}</span>
</div>
<dl className="mt-2 grid grid-cols-1 gap-x-6 gap-y-1.5 sm:grid-cols-2">
{rows.map((r) => (
<div key={r.label} className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-faint">{r.label}</dt>
<dd
className={`tnum text-xs font-semibold ${
r.tone === 'amber' ? 'text-warn' : r.tone === 'emerald' ? 'text-good' : 'text-ink'
}`}
>
{r.value}
</dd>
</div>
))}
</dl>
</div>
);
}

View File

@ -0,0 +1,258 @@
import { useState } from 'react';
import { Bot, ChevronRight, Clock, User, Wrench } from 'lucide-react';
import { Badge } from './core';
import type { AuditEntry, Row } from '../api/types';
import { POLICY } from '../api/config';
import { moneyShort, num, pct, ratio, str } from '../format';
/**
* The whole life of one file, in order, with every step attributed.
*
* WHAT THIS IS FOR. Anyone can be told "an agent assessed this". A trace lets
* them check it: each step names who performed it, when, how long after the
* previous one, and exactly what they submitted. The agent's step sits in the
* same list as the humans', on the same terms, because that is the honest
* claim it is a member of staff on a chain of custody, not a black box
* bolted to the side.
*
* Every row is read from the workflow's own audit trail (`/view/audit`).
* "Credit Assessor AI" appears because the platform recorded the agent
* submitting under its own JWT. Nothing on this screen is asserted by the
* frontend.
*
* THE GAP THAT TELLS THE STORY is the elapsed time on the agent's row. On a
* typical file the humans act seconds apart because a person is clicking; the
* agent's row shows a minute and a half of unattended work. That number is the
* demo, and it is why elapsed time is shown at all.
*
* What is NOT here: the agent's internal steps individual model calls, the
* SQL each tool ran, tokens, cost. That lives in
* aiemployee.tbl_ai_trace_events and needs either an org-scoped token or the
* rdbms bridge in 10_trace_view.sql. The tool RESULTS are shown, attributed;
* the raw payloads are not invented.
*/
const AI_ROLES = ['ai_credit_assessor', 'ai_loan_advisor'];
function isAgent(e: AuditEntry): boolean {
return (e.user_roles ?? []).some((r) => AI_ROLES.includes(r));
}
/** Commit rows only. An activity's commit row carries the state it reached;
* everything else in the trail is engine bookkeeping. */
function steps(entries: AuditEntry[]): AuditEntry[] {
return entries
.filter((e) => str(e.execution_state).startsWith('hdfc-state-'))
.slice()
.sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')));
}
function gap(prev?: string, cur?: string): string | null {
if (!prev || !cur) return null;
const ms = new Date(cur).getTime() - new Date(prev).getTime();
if (!Number.isFinite(ms) || ms < 0) return null;
if (ms < 1000) return null;
const s = Math.round(ms / 1000);
if (s < 90) return `+${s}s`;
const m = Math.floor(s / 60);
return `+${m}m ${s % 60}s`;
}
function time(v?: string): string {
if (!v) return '';
const d = new Date(v);
return Number.isNaN(d.getTime())
? v
: d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export function InstanceTrace({ row, audit }: { row: Row; audit: AuditEntry[] }) {
const list = steps(audit);
const [open, setOpen] = useState<string | null>(null);
const agentCount = list.filter(isAgent).length;
if (list.length === 0) {
return <p className="px-1 py-6 text-sm text-faint">No completed steps recorded yet.</p>;
}
return (
<section className="lift rounded-lg border border-line bg-panel">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-ink">Chain of custody</h2>
<p className="mt-0.5 text-xs text-faint">
Every step on this file, in order, from the workflow's audit trail
</p>
</div>
<div className="flex items-center gap-2">
<Badge tone="violet">{agentCount} by an agent</Badge>
<Badge tone="slate">{list.length - agentCount} by people</Badge>
</div>
</header>
<ol className="px-4 py-2">
{list.map((e, i) => {
const agent = isAgent(e);
const key = `${e.id}-${i}`;
const isOpen = open === key;
const elapsed = gap(list[i - 1]?.created_at, e.created_at);
const fields = e.fields ?? [];
return (
<li key={key} className="border-b border-line last:border-0">
<button
onClick={() => setOpen(isOpen ? null : key)}
className="flex w-full items-start gap-3 py-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand"
>
{/* The rail: step number, and a glyph that says human or not. */}
<span className="flex shrink-0 flex-col items-center gap-1 pt-0.5">
<span
className={`rounded-full p-1.5 ring-1 ring-inset ${
agent ? 'bg-ai-soft text-ai ring-ai-edge' : 'bg-panel2 text-muted ring-line'
}`}
>
{agent ? <Bot className="h-3.5 w-3.5" /> : <User className="h-3.5 w-3.5" />}
</span>
<span className="tnum text-[10px] text-faint">{i + 1}</span>
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-ink">
{str(e.activity_name) || str(e.activity_id)}
</span>
{agent && <Badge tone="violet">agent</Badge>}
{/* Elapsed since the previous step. On the agent's row this
is the unattended minute and a half. */}
{elapsed && (
<span
className={`tnum inline-flex items-center gap-1 text-[11px] ${
agent ? 'font-semibold text-ai' : 'text-faint'
}`}
>
<Clock className="h-3 w-3" />
{elapsed}
</span>
)}
</span>
<span className="mt-0.5 block text-xs text-muted">
{str(e.user_name) || 'System'}
{(e.user_roles ?? []).length > 0 && (
<span className="text-faint"> · {(e.user_roles ?? []).join(', ')}</span>
)}
{fields.length > 0 && (
<span className="text-faint">
{' · '}
{fields.length} {fields.length === 1 ? 'field' : 'fields'} submitted
</span>
)}
</span>
</span>
<span className="flex shrink-0 items-center gap-2 pt-0.5">
<span className="tnum text-xs text-faint">{time(e.created_at)}</span>
<ChevronRight
className={`h-4 w-4 text-faint transition-transform ${isOpen ? 'rotate-90' : ''}`}
/>
</span>
</button>
{isOpen && (
<div className="space-y-3 pb-4 pl-11">
{/* On the agent's step, what it consulted before deciding. */}
{agent && <ToolsUsed row={row} />}
{fields.length === 0 ? (
<p className="text-xs text-faint">This step submitted no fields.</p>
) : (
<div className="rounded-md border border-line bg-panel2">
<p className="border-b border-line px-3 py-1.5 text-[11px] font-semibold tracking-wide text-faint uppercase">
Submitted
</p>
<dl className="divide-y divide-line">
{fields
.filter((f) => str(f.value) !== '')
.map((f) => (
<div key={f.field_id} className="px-3 py-2">
<dt className="text-[11px] font-semibold text-faint">{f.label}</dt>
<dd
className={`mt-0.5 text-xs text-ink ${
f.data_type === 'longtext' ? 'leading-relaxed' : 'tnum'
}`}
>
{f.data_type === 'number'
? moneyShort(f.value)
: str(f.value)}
</dd>
</div>
))}
</dl>
</div>
)}
</div>
)}
</li>
);
})}
</ol>
</section>
);
}
/** The deterministic checks the assessor consulted, and what they returned for
* this file. The figures are the ones on the instance; the attribution is the
* fixed mapping in 09_agent_tools.sql. */
function ToolsUsed({ row }: { row: Row }) {
const isPersonal = str(row.loan_product_family) === 'personal';
const variance = num(row.income_variance_pct);
const breach = variance !== null && variance > POLICY.varianceTolerancePct;
const tools: Array<{ name: string; asked: string; got: string; tone?: 'amber' }> = [
{
name: 'turnover corroboration',
asked: 'compare the declared income sources',
got: isPersonal
? `${moneyShort(row.itr_declared_income)} declared vs ${moneyShort(row.bank_credits_12m)} banked · spread ${pct(variance)} vs ${POLICY.varianceTolerancePct}% tolerance`
: `${moneyShort(row.itr_declared_income)} tax · ${moneyShort(row.gst_turnover_12m)} GST · ${moneyShort(row.bank_credits_12m)} bank · spread ${pct(variance)} vs ${POLICY.varianceTolerancePct}% tolerance`,
...(breach ? { tone: 'amber' as const } : {}),
},
{
name: 'capacity',
asked: 'can this borrower carry the facility',
got: isPersonal
? `FOIR ${ratio((num(row.foir) ?? 0) * 100, 1)}% vs ${(POLICY.foirCeiling * 100).toFixed(0)}% ceiling · eligible up to ${moneyShort(row.max_eligible_amount)}`
: `DSCR ${ratio(row.dscr)} vs ${POLICY.dscrFloor} floor · eligible up to ${moneyShort(row.max_eligible_amount)}`,
},
{
name: 'standing and conduct',
asked: 'is the credit record clean',
got: `bureau ${str(row.bureau_score) || '—'} vs ${POLICY.bureauFloor} floor${
isPersonal ? '' : ` · CMR-${str(row.cmr_rank) || '—'} vs ceiling ${POLICY.cmrCeiling}`
} · DPD ${str(row.live_dpd_max) || '0'}d · ${str(row.bounce_count) || '0'} cheque returns`,
},
];
return (
<div className="rounded-md border border-ai-edge bg-ai-soft">
<p className="flex items-center gap-1.5 border-b border-ai-edge px-3 py-1.5 text-[11px] font-semibold tracking-wide text-ai uppercase">
<Wrench className="h-3 w-3" />
Checks it ran before writing
</p>
<ul className="divide-y divide-ai-edge">
{tools.map((t) => (
<li key={t.name} className="px-3 py-2">
<p className="text-[11px] font-semibold text-ai">{t.name}</p>
<p className="text-[11px] text-ai/70">asked: {t.asked}</p>
<p className={`tnum mt-0.5 text-xs ${t.tone === 'amber' ? 'text-warn' : 'text-ink'}`}>
{t.got}
</p>
</li>
))}
</ul>
<p className="border-t border-ai-edge px-3 py-1.5 text-[11px] text-ai/70">
Each is deterministic SQL running the bank's own formula the agent quotes what it is
given and computes nothing itself.
</p>
</div>
);
}