feat: show and filter the decided_by actor in the approval records

The approval list and detail return decided_by exactly as stored, and the
decided_by filter matches that same value. Rows decided before the column
existed show "Legacy" and match no decided_by filter; their decision_source
stays in its own column and nothing is rewritten.

UI: a "Decided by" column (Human, Rule, Model, System) and a matching filter
beside the decision-source filter, including "AI-approved only" for model.
Types and the API query carry decided_by the same way decision_source does.
This commit is contained in:
BaaRai
2026-11-09 22:35:09 +01:00
parent 8f50b81792
commit 2218480eba
8 changed files with 117 additions and 26 deletions
+7 -8
View File
@@ -255,14 +255,12 @@ const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE
WHEN ip.rule_id IS NOT NULL THEN 'rule'
WHEN lower(ip.reason) LIKE '` + ModelReasonPrefix + `%' OR ip.reason LIKE '` + legacyModelReasonPrefix + `%' THEN 'model' ELSE 'unknown' END)`
// Rows decided before decided_by existed keep it empty; show their decision
// source instead of rewriting history. A pending row has no deciding actor yet,
// so its empty value is returned as is.
const approvalDecidedBy = `CASE WHEN ip.decided_by<>'' OR ip.status='pending' THEN ip.decided_by ELSE ` + approvalDecisionSource + ` END`
// decided_by is returned exactly as stored, never merged with decision_source:
// rows decided before the column existed keep it empty, so the displayed value
// is always the one the decided_by filter matches.
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ` + approvalDecisionSource + `,
` + approvalDecidedBy + `,
ip.decided_by,
COALESCE(c.title,'') AS conv_title,
COALESCE(c.agent_key,'') AS conv_agent_key,
COALESCE(ir.name,'') AS rule_name`
@@ -374,8 +372,9 @@ func (d *DB) listInterceptsPage(taskID string, page, size int, filter InterceptA
add("ip.task_id", taskID)
add("ip.status", filter.Status)
add(approvalDecisionSource, filter.DecisionSource)
// Stored actor only: the legacy display fallback would let decided_by=model
// match human decisions and timeouts recorded before the column existed.
// Stored actor only, the same value the rows display: deriving it from
// decision_source would let decided_by=model match human decisions and
// timeouts recorded before the column existed.
add("ip.decided_by", filter.DecidedBy)
where := ""
if len(conditions) > 0 {
+27 -8
View File
@@ -129,22 +129,41 @@ func TestInterceptDecidedBy(t *testing.T) {
}
}
// Rows decided before the column existed: displayed through decision_source,
// never rewritten, and not matched by the stored-actor filter.
// Rows decided before the column existed: decided_by stays empty everywhere it
// is read, decision_source keeps its own column, nothing is rewritten, and no
// decided_by filter matches them.
if _, err := d.Exec(`UPDATE intercept_pending SET decided_by='' WHERE id IN ($1,$2,$3)`, ruleID, modelID, timeoutID); err != nil {
t.Fatal(err)
}
for id, source := range map[int64]string{ruleID: "rule", modelID: "model", timeoutID: "model"} {
legacy := map[int64]string{ruleID: "rule", modelID: "model", timeoutID: "model"}
for id, source := range legacy {
detail, err := d.GetInterceptDetail(id)
if err != nil || detail.DecidedBy != source || detail.DecisionSource != source {
t.Fatalf("legacy fallback for %d: %+v err=%v, want %q", id, detail, err, source)
if err != nil || detail == nil || detail.DecidedBy != "" || detail.DecisionSource != source {
t.Fatalf("legacy detail for %d: %+v err=%v, want decided_by \"\" and decision_source %q", id, detail, err, source)
}
stored, err := d.GetInterceptPending(id)
if err != nil || stored.DecidedBy != "" {
if err != nil || stored == nil || stored.DecidedBy != "" {
t.Fatalf("legacy row %d rewritten: %+v err=%v", id, stored, err)
}
}
if got := filtered(InterceptApprovalFilter{DecidedBy: "model"}); len(got) != 0 {
t.Fatalf("legacy rows matched decided_by=model: %v", got)
rows, err = d.ListTaskIntercepts(scope)
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if source, ok := legacy[r.ID]; ok && (r.DecidedBy != "" || r.DecisionSource != source) {
t.Fatalf("legacy list row %d: decided_by=%q decision_source=%q, want \"\" and %q", r.ID, r.DecidedBy, r.DecisionSource, source)
}
}
for _, actor := range []string{"human", "rule", "model", "system"} {
for _, id := range filtered(InterceptApprovalFilter{DecidedBy: actor}) {
if _, ok := legacy[id]; ok {
t.Fatalf("legacy row %d matched decided_by=%s", id, actor)
}
}
}
// decision_source still finds them through its own filter.
if got := filtered(InterceptApprovalFilter{DecisionSource: "rule"}); !slices.Contains(got, ruleID) {
t.Fatalf("legacy rule row missing from decision_source=rule: %v", got)
}
}
+15 -3
View File
@@ -207,13 +207,25 @@ func TestInterceptDecidedByHTTP(t *testing.T) {
}
}
// A row decided before the column existed falls back to decision_source.
// A row decided before the column existed keeps an empty decided_by; its
// decision_source stays in its own field and no decided_by filter returns it.
if _, err := d.Exec(`UPDATE intercept_pending SET decided_by='' WHERE id=$1`, ruleID); err != nil {
t.Fatal(err)
}
rec := do(http.MethodGet, fmt.Sprintf("/api/intercept/history/%d", ruleID), "")
var legacy item
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &legacy) != nil || legacy.DecidedBy == nil || *legacy.DecidedBy != "rule" {
var legacy struct {
DecidedBy *string `json:"decided_by"`
DecisionSource string `json:"decision_source"`
}
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &legacy) != nil ||
legacy.DecidedBy == nil || *legacy.DecidedBy != "" || legacy.DecisionSource != "rule" {
t.Fatalf("legacy detail: %d %s", rec.Code, rec.Body.String())
}
if items, total := list(task + "?decided_by=rule"); total != 0 || len(items) != 0 {
t.Fatalf("legacy row matched decided_by=rule: %+v", items)
}
items, _ = list(task + "?decision_source=rule")
if len(items) != 1 || items[0].ID != ruleID || *items[0].DecidedBy != "" {
t.Fatalf("legacy row under decision_source=rule: %+v", items)
}
}
+57 -5
View File
@@ -108,6 +108,23 @@ function StatusBadge({ status }: { status: string }) {
return <Badge variant={variant}>{labels[status] ?? status}</Badge>;
}
const decidedByLabels: Record<string, string> = { human: "Human", rule: "Rule", model: "Model", system: "System" };
// decided_by is shown exactly as stored, so a row always sits under the
// "Decided by" filter option matching its badge. A decided row without an actor
// predates the column: it reads "Legacy" and matches no option.
function DecidedByBadge({ row }: { row: InterceptApprovalRow }) {
if (row.status === "pending") return <span className="text-muted-foreground">—</span>;
if (!row.decided_by) {
return (
<Badge variant="outline" className="text-muted-foreground">
Legacy
</Badge>
);
}
return <Badge variant="outline">{decidedByLabels[row.decided_by] ?? row.decided_by}</Badge>;
}
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
const reason = row.reason?.replace(/^\[model\]\s*/, "");
return (
@@ -383,6 +400,10 @@ export function ApprovalDetail({
<dd>{fmtTime(row.created_at)}</dd>
<dt className="text-muted-foreground">Decided at</dt>
<dd>{fmtTime(current.decided_at)}</dd>
<dt className="text-muted-foreground">Decided by</dt>
<dd>
<DecidedByBadge row={current} />
</dd>
{audit?.rule_name ? (
<>
<dt className="text-muted-foreground">Rule snapshot</dt>
@@ -527,10 +548,10 @@ function subscribeColumns(onChange: () => void) {
}
function visibleColumnCount() {
const matches = approvalBreakpoints.map((query) => window.matchMedia(query).matches);
return 3 + Number(matches[0]) + Number(matches[1]) + 2 * Number(matches[2]) + 2 * Number(matches[3]);
return 3 + Number(matches[0]) + Number(matches[1]) + 3 * Number(matches[2]) + 2 * Number(matches[3]);
}
function serverColumnCount() {
return 9;
return 10;
}
function ApprovalTable({
@@ -570,6 +591,7 @@ function ApprovalTable({
<TableHead className="hidden lg:table-cell">Matched rule</TableHead>
<TableHead className="hidden xl:table-cell">Arguments</TableHead>
<TableHead className="w-24">Status</TableHead>
<TableHead className="hidden w-24 lg:table-cell">Decided by</TableHead>
<TableHead className="hidden w-36 lg:table-cell">Requested at</TableHead>
<TableHead className="hidden w-36 xl:table-cell">Decided at</TableHead>
</TableRow>
@@ -623,6 +645,9 @@ function ApprovalTable({
<TableCell>
<StatusBadge status={row.status} />
</TableCell>
<TableCell className="hidden lg:table-cell">
<DecidedByBadge row={row} />
</TableCell>
<TableCell className="hidden text-muted-foreground text-xs lg:table-cell">
{fmtTime(row.created_at)}
</TableCell>
@@ -663,7 +688,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
const request = React.useRef(0);
const decisionLock = React.useRef(false);
const filterID = React.useId();
const filtered = Boolean(filter.status || filter.decision_source);
const filtered = Boolean(filter.status || filter.decision_source || filter.decided_by);
// A task can stay mounted while the user switches between task details.
// Reset the cursor so the new scope always starts at its newest records.
@@ -749,7 +774,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
// Optimistically drop the decided item from the independent pending queue
// for instant feedback; the re-fetch below reconciles with server truth.
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
prev.map((row) =>
row.id === id ? { ...row, status: decision, decided_by: "human", decided_at: new Date().toISOString() } : row,
),
);
setPendingRows((prev) => prev.filter((row) => row.id !== id));
setRevision((v) => v + 1);
@@ -840,6 +867,31 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
</SelectContent>
</Select>
</Field>
<Field className="w-full sm:w-40">
<FieldLabel htmlFor={`${filterID}-decided-by`}>Decided by</FieldLabel>
<Select
value={filter.decided_by ?? "all"}
onValueChange={(value) =>
changeFilter({
...filter,
decided_by: value === "all" ? undefined : (value as InterceptApprovalFilter["decided_by"]),
})
}
>
<SelectTrigger id={`${filterID}-decided-by`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All actors</SelectItem>
<SelectItem value="model">AI-approved only</SelectItem>
<SelectItem value="human">Human</SelectItem>
<SelectItem value="rule">Rule</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
{filtered ? (
<Button variant="ghost" size="sm" onClick={() => changeFilter({})}>
Clear filters
@@ -885,7 +937,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
<EmptyTitle>{filtered ? "No approval records match the filters" : "No approval records"}</EmptyTitle>
<EmptyDescription>
{filtered
? "Adjust the approval status or decision source, or clear the filters to see all records."
? "Adjust the approval status, decision source or deciding actor, or clear the filters to see all records."
: "Records will appear here once a rule or model makes an approval decision."}
</EmptyDescription>
</EmptyHeader>
+1
View File
@@ -231,6 +231,7 @@ function interceptPageQuery(page: number, size: number, filter: InterceptApprova
const query = new URLSearchParams({ page: String(page), size: String(size) });
if (filter.status) query.set("status", filter.status);
if (filter.decision_source) query.set("decision_source", filter.decision_source);
if (filter.decided_by) query.set("decided_by", filter.decided_by);
return query.toString();
}
+1
View File
@@ -3496,6 +3496,7 @@ interceptHistory.unshift({
tool_input: { path: "reports/summary.md", content: "# Check summary\n\nThis round of verification is complete; existing evidence and follow-up suggestions are organized." },
status: "allowed",
decision_source: "model",
decided_by: "model",
reason: "[model] Write the existing check conclusions into a local report without modifying business data.",
created_at: T("2026-07-26T08:00:00Z"),
decided_at: T("2026-07-26T08:00:02Z"),
+5 -2
View File
@@ -2431,16 +2431,19 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const decisionSource = q.get("decision_source") || "";
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("invalid approval status");
if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("invalid decision source");
const decidedBy = q.get("decided_by") || "";
if (decidedBy && !["human", "rule", "model", "system"].includes(decidedBy)) throw new Error("invalid decided_by");
const filtered = mockInterceptHistory.filter((row) => {
const source =
row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[model]") ? "model" : "unknown");
return (
(seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
(!status || row.status === status) &&
(!decisionSource || source === decisionSource)
(!decisionSource || source === decisionSource) &&
(!decidedBy || row.decided_by === decidedBy)
);
});
if (!q.has("page") && !q.has("size") && !status && !decisionSource)
if (!q.has("page") && !q.has("size") && !status && !decisionSource && !decidedBy)
return { items: filtered, total: filtered.length };
const page = Math.max(1, Number(q.get("page")) || 1);
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
+4
View File
@@ -1347,6 +1347,9 @@ export interface AssetInterceptRule {
export interface InterceptPending {
decision_source?: "rule" | "model" | "unknown" | "";
// Actor that settled the request, exactly as stored: "" while pending and on
// rows decided before the column existed (shown as "Legacy").
decided_by?: "human" | "rule" | "model" | "system" | "";
id: number;
rule_id?: number;
conversation_id?: number;
@@ -1391,6 +1394,7 @@ export interface JudgeUsage {
export interface InterceptApprovalFilter {
status?: InterceptPending["status"];
decision_source?: "rule" | "model" | "unknown";
decided_by?: "human" | "rule" | "model" | "system";
}
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.