Compare commits

...
Author SHA1 Message Date
BaaRai 52ea3f65c6 fix: rename the model filter option to AI-decided
The option filters decided_by = model, which includes denials the model judge
made, so "AI-approved" was inaccurate. Approved-by-AI is the combination with
status allowed.
2026-11-09 23:17:08 +01:00
BaaRai 2218480eba 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.
2026-11-09 22:35:09 +01:00
BaaRai 8f50b81792 feat: record which actor decided an intercept approval
Add intercept_pending.decided_by (human | rule | model | system; empty while
pending or for rows decided before the column existed). Approval semantics are
unchanged: only the actor is recorded.

- rule-matched and model-judged rows written by CreateDecidedIntercept record
  rule or model;
- ResolveIntercept takes the actor explicitly and rejects any other value
  before touching the database;
- Decide (human allow/deny) records human; timeouts and work-cancelled
  denials record system. Work-cancelled is a system refusal, not a human one.
- decided_by is exposed on the pending and approval records and filterable
  with decided_by=human|rule|model|system (anything else is a 400).
- Legacy rows show decision_source as their actor when decided_by is empty.
  The filter uses the stored column only, so decided_by=model cannot match
  older human decisions or timeouts.
- Archive restore fills decided_by for archives that predate the column, and
  marks a restored pending row as system.
2026-11-09 22:17:56 +01:00
BaaRai 3e75f1f954 Merge pull request #164 from BaaRai/feat/retest-verification
feat: a completed retest updates the verification state
2026-11-09 21:56:06 +01:00
BaaRai 8634ddbf23 feat: a completed retest updates the verification state
A completed retest with a reproduced or fixed verdict is an independent proof,
so it sets verification_status to verified with a note naming the retest, its
verdict and the start of its summary. The upgrade applies only from unverified,
not_assessed or blocked: a verified finding keeps its POE level and note.

Inconclusive, failed and stopped retests never change verification_status; their
verdict stays in the retest history. The human status is untouched by this path.
The existing fixed path (status set to Fixed with notifications) is unchanged.

Tests cover reproduced, fixed (status stays Fixed), inconclusive on a verified
finding, failed and stopped, and the upgrade from not_assessed and blocked.
2026-11-09 21:47:40 +01:00
BaaRai ec89070b8e Merge pull request #163 from BaaRai/feat/verification-ui
feat: verification badge, filter and note in the findings UI
2026-11-09 21:21:42 +01:00
16 changed files with 669 additions and 34 deletions
+27 -2
View File
@@ -12,6 +12,19 @@ import (
const FindingRetestAgentKey = "retester"
// retestNoteSummaryRunes caps the retest summary copied into verification_note.
const retestNoteSummaryRunes = 200
// truncateRunes returns s trimmed to at most n runes, with an ellipsis when cut.
func truncateRunes(s string, n int) string {
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
var ErrRetestNotRunning = errors.New("this retest has ended or has not started; please start a new retest from the finding details")
// FindingRetest is an immutable historical test once its conversation turn ends.
@@ -225,17 +238,29 @@ func (d *DB) FinishFindingRetest(id int64, status, reason string) error {
if err != nil {
return err
}
var finalStatus, verdict string
var finalStatus, verdict, summary string
err = tx.QueryRow(`UPDATE finding_retests SET
status=CASE WHEN $2='completed' AND verdict='' THEN 'failed' ELSE $2 END,
error=CASE WHEN $2='completed' AND verdict='' THEN 'Agent did not save the retest conclusion; please review the conversation and retest again' ELSE $3 END,
finished_at=now() WHERE id=$1 AND status IN ('pending','running') RETURNING status,verdict`, id, status, reason).Scan(&finalStatus, &verdict)
finished_at=now() WHERE id=$1 AND status IN ('pending','running') RETURNING status,verdict,summary`, id, status, reason).Scan(&finalStatus, &verdict, &summary)
if errors.Is(err, sql.ErrNoRows) {
return nil // A replay must not overwrite a later manual triage decision.
}
if err != nil {
return err
}
// A completed reproduced/fixed retest is an independent proof: it upgrades the
// machine verification state, but only from unverified / not_assessed / blocked.
// A verified finding keeps its POE level and note. An inconclusive, failed or
// stopped retest never downgrades anything; its verdict stays in finding_retests.
// The human disposition (status) is not touched here.
if finalStatus == "completed" && (verdict == "reproduced" || verdict == "fixed") {
note := fmt.Sprintf("retest #%d: %s — %s", id, verdict, truncateRunes(summary, retestNoteSummaryRunes))
if _, err := tx.Exec(`UPDATE findings SET verification_status='verified', verification_note=$2
WHERE id=$1 AND verification_status IN ('unverified','not_assessed','blocked')`, findingID, note); err != nil {
return err
}
}
if finalStatus == "completed" && verdict == "fixed" {
// Go through the notifying version, sharing the same semantics as a
// manual status change on the details page.
+107
View File
@@ -0,0 +1,107 @@
package db
import (
"strings"
"testing"
)
// runRetest drives one retest session to its terminal state with the given
// staged verdict, using the same calls the server makes, and returns its ID.
func runRetest(t *testing.T, d *DB, fid int64, verdict, summary, finish string) int64 {
t.Helper()
ctx := t.Context()
r, c, _, err := d.CreateFindingRetest(ctx, fid, "")
if err != nil {
t.Fatal(err)
}
if ok, err := d.StartFindingRetest(ctx, r.ID); err != nil || !ok {
t.Fatalf("start retest: ok=%v err=%v", ok, err)
}
if verdict != "" {
if err := d.RecordFindingRetestResult(ctx, c.ID, verdict, summary, "retest evidence"); err != nil {
t.Fatal(err)
}
}
if err := d.FinishFindingRetest(r.ID, finish, ""); err != nil {
t.Fatal(err)
}
return r.ID
}
func verificationOf(t *testing.T, d *DB, fid int64) (status string, poe int, note string) {
t.Helper()
if err := d.QueryRow(`SELECT verification_status, poe_level, verification_note FROM findings WHERE id=$1`, fid).
Scan(&status, &poe, &note); err != nil {
t.Fatal(err)
}
return
}
func TestRetestReproducedVerifiesUnverifiedFinding(t *testing.T) {
d, fid := retestDB(t)
runRetest(t, d, fid, "reproduced", "still exploitable with the original PoC", "completed")
status, _, note := verificationOf(t, d, fid)
if status != VerificationVerified || !strings.Contains(note, "retest") || !strings.Contains(note, "reproduced") {
t.Fatalf("verification after reproduced: status=%s note=%q", status, note)
}
}
func TestRetestFixedVerifiesAndKeepsFixedStatus(t *testing.T) {
d, fid := retestDB(t)
runRetest(t, d, fid, "fixed", "payload now rejected, control still works", "completed")
status, _, note := verificationOf(t, d, fid)
if status != VerificationVerified || !strings.Contains(note, "retest") || !strings.Contains(note, "fixed") {
t.Fatalf("verification after fixed: status=%s note=%q", status, note)
}
f, err := d.GetFinding(fid)
if err != nil || f == nil {
t.Fatalf("get finding: %v", err)
}
if f.Status != FindingFixed {
t.Fatalf("human status after fixed retest = %q, want %q", f.Status, FindingFixed)
}
}
func TestRetestInconclusiveDoesNotDowngradeVerified(t *testing.T) {
d, fid := retestDB(t)
if err := d.SetFindingVerification(fid, VerificationVerified, 3, ""); err != nil {
t.Fatal(err)
}
runRetest(t, d, fid, "inconclusive", "target unreachable from the retest session", "completed")
status, poe, note := verificationOf(t, d, fid)
if status != VerificationVerified || poe != 3 || note != "" {
t.Fatalf("verified finding after inconclusive: status=%s poe=%d note=%q", status, poe, note)
}
}
func TestRetestFailedOrStoppedLeavesVerificationUnchanged(t *testing.T) {
for _, finish := range []string{"failed", "stopped"} {
t.Run(finish, func(t *testing.T) {
d, fid := retestDB(t)
if err := d.SetFindingVerification(fid, VerificationNotAssessed, 3, "earlier note"); err != nil {
t.Fatal(err)
}
// A verdict staged before the session was cut off must not count.
runRetest(t, d, fid, "reproduced", "staged before interruption", finish)
status, poe, note := verificationOf(t, d, fid)
if status != VerificationNotAssessed || poe != 3 || note != "earlier note" {
t.Fatalf("after %s: status=%s poe=%d note=%q", finish, status, poe, note)
}
})
}
}
func TestRetestUpgradesFromNotAssessedAndBlocked(t *testing.T) {
for _, from := range []string{VerificationNotAssessed, VerificationBlocked} {
t.Run(from, func(t *testing.T) {
d, fid := retestDB(t)
if err := d.SetFindingVerification(fid, from, 0, ""); err != nil {
t.Fatal(err)
}
runRetest(t, d, fid, "reproduced", "reproduced on retest", "completed")
if status, _, _ := verificationOf(t, d, fid); status != VerificationVerified {
t.Fatalf("from %s after reproduced: status=%s", from, status)
}
})
}
}
+26 -8
View File
@@ -27,6 +27,8 @@ type InterceptRule struct {
}
// InterceptPending is one row of intercept_pending.
// DecidedBy is the actor that settled the request: "" while pending, otherwise
// human | rule | model | system (timeouts and cancellations).
type InterceptPending struct {
ID int64 `json:"id"`
RuleID *int64 `json:"rule_id"`
@@ -37,6 +39,7 @@ type InterceptPending struct {
ToolInput json.RawMessage `json:"tool_input"`
Status string `json:"status"`
DecisionSource string `json:"decision_source"`
DecidedBy string `json:"decided_by"`
Reason string `json:"reason"` // rule message or model decision reason (prefixed with ModelReasonPrefix)
DecidedAt *time.Time `json:"decided_at"`
CreatedAt time.Time `json:"created_at"`
@@ -163,19 +166,25 @@ func (d *DB) CreateDecidedIntercept(ruleID, convID int64, taskID, agentName, too
if ruleID != 0 {
ruleIDPtr = &ruleID
}
// Rows logged here were settled without a human: by the matching rule, or by
// the model judge when no rule owns the decision.
decidedBy := "model"
if ruleID != 0 {
decidedBy = "rule"
}
var id int64
err := d.QueryRow(`
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, decision_source, audit)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10) RETURNING id`,
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, status, reason, interceptSource(ruleID, reason), firstAudit(audits)).Scan(&id)
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, decision_source, audit, decided_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10, $11) RETURNING id`,
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, status, reason, interceptSource(ruleID, reason), firstAudit(audits), decidedBy).Scan(&id)
return id, err
}
const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, created_at, decision_source`
const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, created_at, decision_source, decided_by`
func scanInterceptPending(s interface{ Scan(...any) error }, p *InterceptPending) error {
return s.Scan(&p.ID, &p.RuleID, &p.ConversationID, &p.TaskID, &p.AgentName,
&p.ToolName, &p.ToolInput, &p.Status, &p.Reason, &p.DecidedAt, &p.CreatedAt, &p.DecisionSource)
&p.ToolName, &p.ToolInput, &p.Status, &p.Reason, &p.DecidedAt, &p.CreatedAt, &p.DecisionSource, &p.DecidedBy)
}
// ListPendingIntercepts returns all unresolved approval requests, newest first.
@@ -221,7 +230,7 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept
return rows.Scan(
&r.ID, &r.RuleID, &r.ConversationID, &r.TaskID, &r.AgentName,
&r.ToolName, &r.ToolInput, &r.Status, &r.Reason, &r.DecidedAt, &r.CreatedAt,
&r.DecisionSource, &r.ConvTitle, &r.ConvAgentKey, &r.RuleName,
&r.DecisionSource, &r.DecidedBy, &r.ConvTitle, &r.ConvAgentKey, &r.RuleName,
)
}
@@ -246,8 +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)`
// 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 + `,
ip.decided_by,
COALESCE(c.title,'') AS conv_title,
COALESCE(c.agent_key,'') AS conv_agent_key,
COALESCE(ir.name,'') AS rule_name`
@@ -300,11 +313,12 @@ func (d *DB) ListAllIntercepts(limit int) ([]InterceptApprovalRow, error) {
return out, rows.Err()
}
// InterceptApprovalFilter combines exact status and decision-source filters.
// Empty fields include all values.
// InterceptApprovalFilter combines exact status, decision-source and
// deciding-actor filters. Empty fields include all values.
type InterceptApprovalFilter struct {
Status string
DecisionSource string
DecidedBy string
}
// ListAllInterceptsPage returns one 1-based page and the total matching count.
@@ -358,6 +372,10 @@ 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 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 {
where = " WHERE " + strings.Join(conditions, " AND ")
+169
View File
@@ -0,0 +1,169 @@
package db
import (
"slices"
"testing"
)
// The actor whitelist is enforced in Go (there is no SQL CHECK). It must reject
// before touching the database, so this runs without Postgres.
func TestResolveInterceptRejectsUnknownActor(t *testing.T) {
d := &DB{}
for _, actor := range []string{"", "unknown", "Human", "admin"} {
if ok, err := d.ResolveIntercept(1, "allowed", "allow", "fixture", actor); err == nil || ok {
t.Fatalf("decided_by %q accepted: ok=%t err=%v", actor, ok, err)
}
}
}
func TestInterceptDecidedBy(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.Close() })
scope := "approval-decided-by"
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, scope) })
rule, err := d.CreateInterceptRule("decided-by fixture", "tool_name", "string", "Bash", "deny", "fixture", 1, false, false, 0, "deny")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_rules WHERE id=$1`, rule.ID) })
input := []byte(`{"command":"fixture"}`)
decided := func(ruleID int64, status, reason string) int64 {
t.Helper()
id, err := d.CreateDecidedIntercept(ruleID, 0, scope, "test", "Bash", input, status, reason)
if err != nil {
t.Fatal(err)
}
return id
}
asked := func() int64 {
t.Helper()
id, err := d.CreateInterceptPending(0, 0, scope, "test", "Bash", input, ModelReasonPrefix+" confirm")
if err != nil {
t.Fatal(err)
}
return id
}
resolved := func(status, action, reason, actor string) int64 {
t.Helper()
id := asked()
if ok, err := d.ResolveIntercept(id, status, action, reason, actor); err != nil || !ok {
t.Fatalf("resolve %s/%s: ok=%t err=%v", status, actor, ok, err)
}
return id
}
ruleID := decided(rule.ID, "denied", "fixture")
modelID := decided(0, "allowed", ModelReasonPrefix+" fixture")
pendingID := asked()
humanAllowID := resolved("allowed", "allow", "user approved execution", "human")
humanDenyID := resolved("denied", "deny", "user rejected execution", "human")
timeoutID := resolved("timeout", "deny", "approval timed out, applying timeout policy", "system")
// A cancellation is a system refusal even though its status is 'denied'.
cancelledID := resolved("denied", "deny", "work cancelled", "system")
want := map[int64]string{
ruleID: "rule", modelID: "model", pendingID: "",
humanAllowID: "human", humanDenyID: "human",
timeoutID: "system", cancelledID: "system",
}
for id, actor := range want {
stored, err := d.GetInterceptPending(id)
if err != nil || stored == nil || stored.DecidedBy != actor {
t.Fatalf("stored decided_by for %d: %+v err=%v, want %q", id, stored, err, actor)
}
detail, err := d.GetInterceptDetail(id)
if err != nil || detail == nil || detail.DecidedBy != actor {
t.Fatalf("detail decided_by for %d: %+v err=%v, want %q", id, detail, err, actor)
}
}
rows, err := d.ListTaskIntercepts(scope)
if err != nil || len(rows) != len(want) {
t.Fatalf("list: %d rows err=%v", len(rows), err)
}
for _, r := range rows {
if r.DecidedBy != want[r.ID] {
t.Fatalf("listed decided_by for %d = %q, want %q", r.ID, r.DecidedBy, want[r.ID])
}
}
// A rejected actor must leave the request pending and unattributed.
if ok, err := d.ResolveIntercept(pendingID, "allowed", "allow", "fixture", "admin"); err == nil || ok {
t.Fatalf("invalid actor resolved the request: ok=%t err=%v", ok, err)
}
if p, err := d.GetInterceptPending(pendingID); err != nil || p.Status != "pending" || p.DecidedBy != "" {
t.Fatalf("pending row changed: %+v err=%v", p, err)
}
filtered := func(filter InterceptApprovalFilter) []int64 {
t.Helper()
rows, total, err := d.ListTaskInterceptsPage(scope, 1, 100, filter)
if err != nil || total != len(rows) {
t.Fatalf("filter %+v: total=%d rows=%d err=%v", filter, total, len(rows), err)
}
ids := []int64{}
for _, r := range rows {
ids = append(ids, r.ID)
}
slices.Sort(ids)
return ids
}
for _, tc := range []struct {
filter InterceptApprovalFilter
want []int64
}{
// Neither the human decisions nor the timeout of a model-raised ask.
{InterceptApprovalFilter{DecidedBy: "model"}, []int64{modelID}},
{InterceptApprovalFilter{DecidedBy: "rule"}, []int64{ruleID}},
{InterceptApprovalFilter{DecidedBy: "human"}, []int64{humanAllowID, humanDenyID}},
{InterceptApprovalFilter{DecidedBy: "system"}, []int64{timeoutID, cancelledID}},
{InterceptApprovalFilter{Status: "denied", DecidedBy: "human"}, []int64{humanDenyID}},
{InterceptApprovalFilter{Status: "denied", DecidedBy: "system"}, []int64{cancelledID}},
{InterceptApprovalFilter{DecidedBy: "human' OR 1=1 --"}, []int64{}},
} {
if got := filtered(tc.filter); !slices.Equal(got, tc.want) {
t.Fatalf("filter %+v: got %v want %v", tc.filter, got, tc.want)
}
}
// 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)
}
legacy := map[int64]string{ruleID: "rule", modelID: "model", timeoutID: "model"}
for id, source := range legacy {
detail, err := d.GetInterceptDetail(id)
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 == nil || stored.DecidedBy != "" {
t.Fatalf("legacy row %d rewritten: %+v err=%v", id, stored, err)
}
}
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)
}
}
+12 -4
View File
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
@@ -55,7 +56,7 @@ func (d *DB) GetInterceptDetail(id int64) (*InterceptDetail, error) {
err := d.QueryRow(approvalRowSelectWithAudit+` WHERE ip.id=$1`, id).Scan(
&out.ID, &out.RuleID, &out.ConversationID, &out.TaskID, &out.AgentName,
&out.ToolName, &out.ToolInput, &out.Status, &out.Reason, &out.DecidedAt, &out.CreatedAt,
&out.DecisionSource, &out.ConvTitle, &out.ConvAgentKey, &out.RuleName, &raw,
&out.DecisionSource, &out.DecidedBy, &out.ConvTitle, &out.ConvAgentKey, &out.RuleName, &raw,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -73,7 +74,14 @@ func (d *DB) GetInterceptDetail(id int64) (*InterceptDetail, error) {
// ResolveIntercept atomically settles a pending request. A timeout cannot
// overwrite a human decision and repeat decisions cannot rewrite history.
func (d *DB) ResolveIntercept(id int64, status, action, reason string) (bool, error) {
// decidedBy records the settling actor (human | rule | model | system); it is
// supplied by the caller because status alone cannot tell them apart.
func (d *DB) ResolveIntercept(id int64, status, action, reason, decidedBy string) (bool, error) {
switch decidedBy {
case "human", "rule", "model", "system":
default:
return false, fmt.Errorf("decided_by must be human, rule, model or system, got %q", decidedBy)
}
execution := "not_executed"
if action == "allow" {
execution = "awaiting_result"
@@ -84,11 +92,11 @@ func (d *DB) ResolveIntercept(id int64, status, action, reason string) (bool, er
if err != nil {
return false, err
}
r, err := d.Exec(`UPDATE intercept_pending SET status=$2, decided_at=NOW(),
r, err := d.Exec(`UPDATE intercept_pending SET status=$2, decided_at=NOW(), decided_by=$5,
audit=CASE WHEN audit IS NULL THEN NULL ELSE audit || $3::jsonb ||
CASE WHEN $4='allow' AND audit->>'correlation' IS DISTINCT FROM 'exact'
THEN '{"execution_status":"unknown"}'::jsonb ELSE '{}'::jsonb END END
WHERE id=$1 AND status='pending'`, id, status, patch, action)
WHERE id=$1 AND status='pending'`, id, status, patch, action, decidedBy)
if err != nil {
return false, err
}
+10 -8
View File
@@ -49,7 +49,7 @@ func TestInterceptDetails(t *testing.T) {
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
ok, err := d.ResolveIntercept(id, "allowed", "allow", "allowed manually")
ok, err := d.ResolveIntercept(id, "allowed", "allow", "allowed manually", "human")
if err != nil {
t.Error(err)
}
@@ -62,7 +62,7 @@ func TestInterceptDetails(t *testing.T) {
if wins.Load() != 1 {
t.Fatalf("%d decisions won", wins.Load())
}
ok, err := d.ResolveIntercept(id, "timeout", "deny", "late timeout")
ok, err := d.ResolveIntercept(id, "timeout", "deny", "late timeout", "system")
if err != nil || ok {
t.Fatal("timeout overwrote decision")
}
@@ -90,7 +90,7 @@ func TestInterceptDetails(t *testing.T) {
})
t.Run("denied output and timeout allow", func(t *testing.T) {
id := create(t, &InterceptAudit{RunID: "run", ToolUseID: "call", ExecutionStatus: "not_started"})
if _, err := d.ResolveIntercept(id, "denied", "deny", "denied manually"); err != nil {
if _, err := d.ResolveIntercept(id, "denied", "deny", "denied manually", "human"); err != nil {
t.Fatal(err)
}
if err := d.CompleteIntercept(id, "run", "call", "failed", "Blocked by hook", false); err != nil {
@@ -101,7 +101,7 @@ func TestInterceptDetails(t *testing.T) {
t.Fatal("denial presented as executed")
}
id = create(t, &InterceptAudit{RunID: "run2", ToolUseID: "call2", Correlation: "exact", InitialAction: "ask"})
if _, err := d.ResolveIntercept(id, "timeout", "allow", "timeout allow"); err != nil {
if _, err := d.ResolveIntercept(id, "timeout", "allow", "timeout allow", "system"); err != nil {
t.Fatal(err)
}
if err := d.CompleteIntercept(id, "run2", "call2", "succeeded", "ok", false); err != nil {
@@ -126,6 +126,7 @@ func TestInterceptDetails(t *testing.T) {
if legacy {
delete(row, "audit")
delete(row, "decision_source")
delete(row, "decided_by")
}
archived, _ := json.Marshal([]map[string]any{row})
tx, err := d.Begin()
@@ -139,13 +140,14 @@ func TestInterceptDetails(t *testing.T) {
if err := restoreInterceptRows(tx, archived); err != nil {
t.Fatalf("legacy=%t: %v", legacy, err)
}
var status, source string
var status, source, decidedBy string
var auditJSON []byte
if err := tx.QueryRow(`SELECT status, decision_source, audit FROM intercept_pending WHERE id=$1`, id).Scan(&status, &source, &auditJSON); err != nil {
if err := tx.QueryRow(`SELECT status, decision_source, decided_by, audit FROM intercept_pending WHERE id=$1`, id).Scan(&status, &source, &decidedBy, &auditJSON); err != nil {
t.Fatal(err)
}
if status != "timeout" || source != "model" {
t.Fatalf("restored %s/%s", status, source)
// The restore itself times the request out: a system decision.
if status != "timeout" || source != "model" || decidedBy != "system" {
t.Fatalf("restored %s/%s/%s", status, source, decidedBy)
}
if legacy && len(auditJSON) > 0 {
t.Fatal("fabricated legacy audit")
+4
View File
@@ -1108,6 +1108,10 @@ UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN
UPDATE intercept_pending SET decision_source='model'
WHERE decision_source='unknown' AND rule_id IS NULL
AND (reason LIKE '[model]%' OR reason LIKE '[模型]%');
-- Actor that settled the request: '' (pending, or decided before this column
-- existed) | human | rule | model | system. No backfill: readers fall back to
-- decision_source for legacy rows instead of rewriting history.
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decided_by TEXT NOT NULL DEFAULT '';
-- =====================================================================
-- L. Finding persistence
+5
View File
@@ -682,8 +682,13 @@ func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error {
}
row["decision_source"] = source
}
// Archives predating decided_by lack this NOT NULL column too.
if row["decided_by"] == nil {
row["decided_by"] = ""
}
if status, _ := row["status"].(string); status == "pending" {
row["status"] = "timeout"
row["decided_by"] = "system"
row["reason"] = "approval timed out while the task was archived"
row["decided_at"] = time.Now().UTC()
if audit, ok := row["audit"].(map[string]any); ok {
+4 -4
View File
@@ -637,7 +637,7 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
case allowed := <-ch:
return allowed
case <-ctx.Done():
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "work cancelled")
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "work cancelled", "system")
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "work cancelled before execution", false)
return false
}
@@ -658,7 +658,7 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
if allowed {
action = "allow"
}
resolved, err := i.db.ResolveIntercept(pendingID, "timeout", action, "approval timed out, applying timeout policy")
resolved, err := i.db.ResolveIntercept(pendingID, "timeout", action, "approval timed out, applying timeout policy", "system")
if err != nil {
return false
}
@@ -668,7 +668,7 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
}
return allowed
case <-ctx.Done():
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "work cancelled")
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "work cancelled", "system")
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "work cancelled before execution", false)
return false
}
@@ -686,7 +686,7 @@ func (i *Interceptor) Decide(pendingID int64, allowed bool) error {
if allowed {
action, reason = "allow", "user approved execution"
}
resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason)
resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason, "human")
if err != nil {
return err
}
+6 -1
View File
@@ -321,7 +321,7 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
}
func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source")}
filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source"), DecidedBy: q.Get("decided_by")}
switch filter.Status {
case "", "pending", "allowed", "denied", "timeout":
default:
@@ -332,6 +332,11 @@ func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
default:
return filter, fmt.Errorf("decision_source must be model, rule or unknown")
}
switch filter.DecidedBy {
case "", "human", "rule", "model", "system":
default:
return filter, fmt.Errorf("decided_by must be human, rule, model or system")
}
return filter, nil
}
+231
View File
@@ -0,0 +1,231 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
)
// Pure parameter validation: runs without Postgres.
func TestInterceptFilterParamsDecidedBy(t *testing.T) {
for _, actor := range []string{"", "human", "rule", "model", "system"} {
filter, err := interceptFilterParams(url.Values{"decided_by": {actor}})
if err != nil || filter.DecidedBy != actor {
t.Fatalf("decided_by=%q: %+v err=%v", actor, filter, err)
}
}
for _, actor := range []string{"invalid", "MODEL", "unknown", "human' OR 1=1 --"} {
if _, err := interceptFilterParams(url.Values{"decided_by": {actor}}); err == nil {
t.Fatalf("decided_by=%q accepted", actor)
}
}
}
// Drives every real writer (rule/model log, HTTP decide, timeout, cancellation)
// and reads the actor back through the HTTP lists.
func TestInterceptDecidedByHTTP(t *testing.T) {
dsn, _, err := db.DSN()
if err != nil {
t.Skip("no test database configured")
}
d, err := db.Open(dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.Close() })
m := &Manager{pg: d, interceptor: intercept.New(d)}
s := &Server{m: m, jwtKey: []byte("approval-decided-by-test-key")}
h := s.Handler()
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatal(err)
}
scope := "approval-http-decided-by"
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, scope) })
rule, err := d.CreateInterceptRule("decided-by http fixture", "tool_name", "string", "Bash", "deny", "fixture", 1, false, false, 0, "deny")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_rules WHERE id=$1`, rule.ID) })
do := func(method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// Every step below adds exactly one row to the scope; added returns it.
want := map[int64]string{}
added := func(actor, status string) int64 {
t.Helper()
rows, err := d.ListTaskIntercepts(scope)
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if _, known := want[r.ID]; known {
continue
}
if r.Status != status {
t.Fatalf("new row %d has status %q, want %q", r.ID, r.Status, status)
}
want[r.ID] = actor
return r.ID
}
t.Fatalf("no new row for %s/%s", actor, status)
return 0
}
ctx := intercept.WithTaskContext(context.Background(), scope, "test", nil)
input := []byte(`{"command":"fixture"}`)
modelAsk := intercept.Decision{Action: "ask", Message: db.ModelReasonPrefix + " confirm"}
m.interceptor.Log(ctx, 0, intercept.Decision{Action: "deny", RuleID: rule.ID, Message: "fixture"}, "Bash", input, "denied")
ruleID := added("rule", "denied")
m.interceptor.Log(ctx, 0, intercept.Decision{Action: "allow", Message: db.ModelReasonPrefix + " fixture"}, "Bash", input, "allowed")
modelID := added("model", "allowed")
for _, decision := range []string{"allowed", "denied"} {
id, err := d.CreateInterceptPending(0, 0, scope, "test", "Bash", input, modelAsk.Message)
if err != nil {
t.Fatal(err)
}
if r := do(http.MethodPost, fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"`+decision+`"}`); r.Code != 200 {
t.Fatalf("decide %s: %d %s", decision, r.Code, r.Body.String())
}
if got := added("human", decision); got != id {
t.Fatalf("decided row %d, want %d", got, id)
}
}
timed := modelAsk
timed.TimeoutEnabled, timed.TimeoutSeconds, timed.TimeoutAction = true, 1, "deny"
if m.interceptor.HandleAsk(ctx, 0, timed, "Bash", input) {
t.Fatal("timeout policy deny allowed the call")
}
added("system", "timeout")
// Work cancelled: a system refusal recorded with status 'denied', on both
// the no-timeout and the timeout-armed wait.
cancelled, cancel := context.WithCancel(ctx)
cancel()
if m.interceptor.HandleAsk(cancelled, 0, modelAsk, "Bash", input) {
t.Fatal("cancelled ask allowed the call")
}
added("system", "denied")
timed.TimeoutSeconds = 60
if m.interceptor.HandleAsk(cancelled, 0, timed, "Bash", input) {
t.Fatal("cancelled timed ask allowed the call")
}
added("system", "denied")
if _, err := d.CreateInterceptPending(0, 0, scope, "test", "Bash", input, modelAsk.Message); err != nil {
t.Fatal(err)
}
added("", "pending")
type item struct {
ID int64 `json:"id"`
Status string `json:"status"`
DecidedBy *string `json:"decided_by"`
}
list := func(path string) (items []item, total int) {
t.Helper()
rec := do(http.MethodGet, path, "")
var body struct {
Items []item `json:"items"`
Total int `json:"total"`
}
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body.String())
}
for _, it := range body.Items {
if it.DecidedBy == nil {
t.Fatalf("%s: item %d has no decided_by field", path, it.ID)
}
}
return body.Items, body.Total
}
task := "/api/intercept/task/" + scope
items, total := list(task)
if total != len(want) || len(items) != len(want) {
t.Fatalf("task list: total=%d items=%d want=%d", total, len(items), len(want))
}
for _, it := range items {
if *it.DecidedBy != want[it.ID] {
t.Fatalf("item %d (%s) decided_by=%q, want %q", it.ID, it.Status, *it.DecidedBy, want[it.ID])
}
}
for _, tc := range []struct {
query string
total int
}{
{"?decided_by=model", 1},
{"?decided_by=rule", 1},
{"?decided_by=human", 2},
{"?decided_by=system", 3},
{"?status=denied&decided_by=human", 1},
{"?status=denied&decided_by=system", 2},
{"?status=timeout&decided_by=model", 0},
} {
items, total := list(task + tc.query)
if total != tc.total || len(items) != tc.total {
t.Fatalf("%s: total=%d items=%d want=%d", tc.query, total, len(items), tc.total)
}
for _, it := range items {
if !strings.Contains(tc.query, "decided_by="+*it.DecidedBy) {
t.Fatalf("%s returned item %d decided by %q", tc.query, it.ID, *it.DecidedBy)
}
}
}
if items, _ := list(task + "?decided_by=model"); len(items) != 1 || items[0].ID != modelID {
t.Fatalf("decided_by=model returned %+v, want only %d", items, modelID)
}
// The global history holds other tests' rows too: none of this test's human,
// rule or system decisions may surface under decided_by=model.
items, _ = list("/api/intercept/history?size=100&decided_by=model")
for _, it := range items {
if actor, mine := want[it.ID]; *it.DecidedBy != "model" || mine && actor != "model" {
t.Fatalf("history decided_by=model returned item %d (%s) decided by %q", it.ID, it.Status, *it.DecidedBy)
}
}
for _, base := range []string{"/api/intercept/history", task} {
for _, query := range []string{"?decided_by=invalid", "?decided_by=MODEL", "?decided_by=unknown", "?page=1&decided_by=human%27+OR+1%3D1--"} {
if rec := do(http.MethodGet, base+query, ""); rec.Code != 400 {
t.Fatalf("invalid filter %s%s: %d %s", base, query, rec.Code, rec.Body.String())
}
}
}
// 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 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-decided</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.