diff --git a/README.md b/README.md
index e3523d3..41ca9f5 100644
--- a/README.md
+++ b/README.md
@@ -141,6 +141,14 @@ Adrian supports entirely offline, data sovereign deployments using just a handfu
Use the same `adrian.init` snippet as in the [Quickstart](#quickstart) above. The SDK defaults to `ws://localhost:8080/ws`, so a self-hosted setup needs nothing more than the API key - drop the `ws_url=` line.
+### Classifier error policy
+
+Adrian records classifier outages, malformed classifier responses, and unparseable classifier output as `verdict_status=error` with `mad_code=""`. These are operational classifier errors, not benign `M0` findings and not synthetic malicious activity.
+
+The default policy remains availability-first: classifier errors fail open. In **Settings -> Policy**, enable **Fail closed on classifier error** to make BLOCK-mode tool calls return blocked responses when the classifier cannot produce a verdict. In HITL mode, actionable classifier errors are sent to the review queue and held until an operator approves or rejects them.
+
+Fail-closed classifier-error enforcement requires the Python SDK version shipped with this repository update. Older SDKs ignore the additive protobuf `status` and policy fields, see an empty MAD code, and continue fail-open even when the dashboard toggle is enabled.
+
To [reset the admin password](https://docs.adrian.secureagentics.ai/reference/backend#reset-the-admin-password), [change the model](https://docs.adrian.secureagentics.ai/reference/backend#switch-the-local-gguf) and much more check out the dedicated [Docs site](https://docs.adrian.secureagentics.ai/).
## Why Adrian is different
diff --git a/backend/internal/api/handlers_events.go b/backend/internal/api/handlers_events.go
index eb7cfc4..87676ee 100644
--- a/backend/internal/api/handlers_events.go
+++ b/backend/internal/api/handlers_events.go
@@ -84,13 +84,18 @@ func (s *Server) handleListEvents(w http.ResponseWriter, r *http.Request) {
since = t
}
}
+ if status := q.Get("verdict_status"); status != "" && !validVerdictStatus(status) {
+ writeError(w, http.StatusBadRequest, "invalid verdict_status")
+ return
+ }
filters := store.EventFilters{
- Since: since,
- AgentID: q.Get("agent_id"),
- SessionID: q.Get("session_id"),
- EventType: q.Get("event_type"),
- MinMAD: q.Get("min_mad"),
+ Since: since,
+ AgentID: q.Get("agent_id"),
+ SessionID: q.Get("session_id"),
+ EventType: q.Get("event_type"),
+ MinMAD: q.Get("min_mad"),
+ VerdictStatus: q.Get("verdict_status"),
}
rows, total, err := s.store.ListEvents(r.Context(), filters, pg.PerPage, pg.Offset)
@@ -203,6 +208,7 @@ func eventToListItemResponse(r *store.EventListRow) eventListItemResponse {
ID: r.VerdictID,
MADCode: r.MADCode,
Classification: r.Classification,
+ VerdictStatus: r.VerdictStatus,
}
}
return resp
diff --git a/backend/internal/api/handlers_reviews.go b/backend/internal/api/handlers_reviews.go
index 2ecd782..e8d8847 100644
--- a/backend/internal/api/handlers_reviews.go
+++ b/backend/internal/api/handlers_reviews.go
@@ -39,6 +39,7 @@ type reviewDetail struct {
reviewSummary
EventPayload json.RawMessage `json:"event_payload,omitempty"`
Classification string `json:"classification,omitempty"`
+ Reasoning string `json:"reasoning,omitempty"`
}
type reviewResolveResponse struct {
@@ -49,8 +50,14 @@ type reviewResolveResponse struct {
func (s *Server) handleListReviews(w http.ResponseWriter, r *http.Request) {
pg := parsePagination(r)
- status := r.URL.Query().Get("status")
- rows, total, err := s.store.ListHitlQueue(r.Context(), status, pg.PerPage, pg.Offset)
+ q := r.URL.Query()
+ status := q.Get("status")
+ verdictStatus := q.Get("verdict_status")
+ if verdictStatus != "" && !validVerdictStatus(verdictStatus) {
+ writeError(w, http.StatusBadRequest, "invalid verdict_status")
+ return
+ }
+ rows, total, err := s.store.ListHitlQueue(r.Context(), status, verdictStatus, pg.PerPage, pg.Offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "query failed")
return
@@ -81,6 +88,7 @@ func (s *Server) handleGetReview(w http.ResponseWriter, r *http.Request) {
resp := reviewDetail{
reviewSummary: reviewToSummary(&row.HitlReview),
Classification: row.Classification,
+ Reasoning: row.Reasoning,
}
if row.EventPayloadJSON != "" {
resp.EventPayload = json.RawMessage(row.EventPayloadJSON)
@@ -128,7 +136,7 @@ func (s *Server) resolveReview(w http.ResponseWriter, r *http.Request, status st
EventId: row.EventID,
SessionId: row.SessionID,
MadCode: row.MADCode,
- Status: pb.VerdictStatus_VERDICT_STATUS_OK,
+ Status: reviewVerdictStatusProto(row.VerdictStatus),
Policy: s.policySnapshotProto(pol),
Hitl: &pb.HitlResponse{ContinueExecution: continueExec},
}},
@@ -165,3 +173,14 @@ func reviewToSummary(r *store.HitlReview) reviewSummary {
}
return out
}
+
+func reviewVerdictStatusProto(status string) pb.VerdictStatus {
+ switch status {
+ case "error":
+ return pb.VerdictStatus_VERDICT_STATUS_ERROR
+ case "ok":
+ return pb.VerdictStatus_VERDICT_STATUS_OK
+ default:
+ return pb.VerdictStatus_VERDICT_STATUS_UNSPECIFIED
+ }
+}
diff --git a/backend/internal/api/handlers_stats.go b/backend/internal/api/handlers_stats.go
index 8787369..d6ff5e8 100644
--- a/backend/internal/api/handlers_stats.go
+++ b/backend/internal/api/handlers_stats.go
@@ -6,12 +6,13 @@ package api
import "net/http"
type overviewResponse struct {
- TotalEvents int `json:"total_events"`
- FlaggedVerdicts int `json:"flagged_verdicts"`
- PendingReviews int `json:"pending_reviews"`
- ActiveAgents int `json:"active_agents"`
- VerdictsByMAD map[string]int `json:"verdicts_by_mad"`
- Window string `json:"window"`
+ TotalEvents int `json:"total_events"`
+ FlaggedVerdicts int `json:"flagged_verdicts"`
+ ClassifierErrors int `json:"classifier_errors"`
+ PendingReviews int `json:"pending_reviews"`
+ ActiveAgents int `json:"active_agents"`
+ VerdictsByMAD map[string]int `json:"verdicts_by_mad"`
+ Window string `json:"window"`
}
type activityBucketEntry struct {
@@ -31,12 +32,13 @@ func (s *Server) handleStatsOverview(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, overviewResponse{
- TotalEvents: o.TotalEvents,
- FlaggedVerdicts: o.FlaggedVerdicts,
- PendingReviews: o.PendingReviews,
- ActiveAgents: o.ActiveAgents,
- VerdictsByMAD: o.VerdictsByMAD,
- Window: "24h",
+ TotalEvents: o.TotalEvents,
+ FlaggedVerdicts: o.FlaggedVerdicts,
+ ClassifierErrors: o.ClassifierErrors,
+ PendingReviews: o.PendingReviews,
+ ActiveAgents: o.ActiveAgents,
+ VerdictsByMAD: o.VerdictsByMAD,
+ Window: "24h",
})
}
diff --git a/backend/internal/api/handlers_test.go b/backend/internal/api/handlers_test.go
index 304f1d1..bcd091e 100644
--- a/backend/internal/api/handlers_test.go
+++ b/backend/internal/api/handlers_test.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/google/uuid"
+ "google.golang.org/protobuf/proto"
_ "modernc.org/sqlite"
"github.com/secureagentics/Adrian/backend/internal/api"
@@ -415,7 +416,7 @@ func TestProfileNameValidation(t *testing.T) {
func TestStatsOverview(t *testing.T) {
srv, db, _, cookie := newTestServerLoggedIn(t)
- // Seed: 3 events on 2 agents, 2 verdicts (one M0, one M3),
+ // Seed: 3 events on 2 agents, 3 verdicts (M0, M3, classifier error),
// 1 pending review, 1 agents row with last_seen recent.
if _, err := db.Exec(
`INSERT INTO agents (id, agent_id, last_seen) VALUES (?, 'a1', datetime('now'))`,
@@ -441,6 +442,13 @@ func TestStatsOverview(t *testing.T) {
t.Fatalf("seed verdict: %v", err)
}
}
+ if _, err := db.Exec(
+ `INSERT INTO verdicts (id, event_id, session_id, mad_code, classification, verdict_status)
+ VALUES (?, ?, 'sess-stats', '', 'error', 'error')`,
+ uuid.NewString(), uuid.NewString(),
+ ); err != nil {
+ t.Fatalf("seed error verdict: %v", err)
+ }
if _, err := db.Exec(
`INSERT INTO hitl_queue (id, event_id, session_id, mad_code) VALUES (?, ?, 'sess-stats', 'M3')`,
uuid.NewString(), uuid.NewString(),
@@ -459,6 +467,9 @@ func TestStatsOverview(t *testing.T) {
if int(data["flagged_verdicts"].(float64)) != 1 {
t.Errorf("flagged_verdicts = %v, want 1 (only M3.b counts)", data["flagged_verdicts"])
}
+ if int(data["classifier_errors"].(float64)) != 1 {
+ t.Errorf("classifier_errors = %v, want 1", data["classifier_errors"])
+ }
if int(data["pending_reviews"].(float64)) != 1 {
t.Errorf("pending_reviews = %v, want 1", data["pending_reviews"])
}
@@ -466,8 +477,10 @@ func TestStatsOverview(t *testing.T) {
t.Errorf("active_agents = %v, want 1", data["active_agents"])
}
dist := data["verdicts_by_mad"].(map[string]any)
- if int(dist["M0"].(float64)) != 1 || int(dist["M3"].(float64)) != 1 {
- t.Errorf("verdicts_by_mad = %v, want M0=1 M3=1", dist)
+ if int(dist["M0"].(float64)) != 1 ||
+ int(dist["M3"].(float64)) != 1 ||
+ int(dist["error"].(float64)) != 1 {
+ t.Errorf("verdicts_by_mad = %v, want M0=1 M3=1 error=1", dist)
}
}
@@ -523,6 +536,9 @@ func TestListVerdictsIncludesStatusAndFiltersError(t *testing.T) {
if row["classification"] != "error" || row["verdict_status"] != "error" {
t.Errorf("verdict row = %v, want classification/status error", row)
}
+ if row["reasoning"] != "classifier failed" {
+ t.Errorf("reasoning = %v, want classifier failed", row["reasoning"])
+ }
}
// -----------------------------------------------------------------
@@ -632,6 +648,139 @@ func TestApproveReviewPublishesToSubscriber(t *testing.T) {
}
}
+func TestApproveErrorReviewPublishesErrorStatus(t *testing.T) {
+ srv, db, hub, cookie := newTestServerWithHub(t)
+
+ const sessID = "sess-hitl-error"
+ eventID := uuid.NewString()
+ verdictID := uuid.NewString()
+ queueID := uuid.NewString()
+
+ if _, err := db.Exec(
+ `INSERT INTO events (id, session_id, agent_id, event_type, run_id, payload)
+ VALUES (?, ?, 'agent-h', 'llm', 'r1', '{}')`,
+ eventID, sessID,
+ ); err != nil {
+ t.Fatalf("seed event: %v", err)
+ }
+ if _, err := db.Exec(
+ `INSERT INTO verdicts (id, event_id, session_id, mad_code, classification, verdict_status, reasoning)
+ VALUES (?, ?, ?, '', 'error', 'error', 'classifier failure: boom')`,
+ verdictID, eventID, sessID,
+ ); err != nil {
+ t.Fatalf("seed verdict: %v", err)
+ }
+ if _, err := db.Exec(
+ `INSERT INTO hitl_queue (id, event_id, verdict_id, session_id, mad_code)
+ VALUES (?, ?, ?, ?, '')`,
+ queueID, eventID, verdictID, sessID,
+ ); err != nil {
+ t.Fatalf("seed hitl_queue: %v", err)
+ }
+
+ detailResp := getReq(t, srv, cookie, "/api/reviews/"+queueID)
+ if detailResp.StatusCode != http.StatusOK {
+ t.Fatalf("detail status = %d, want 200", detailResp.StatusCode)
+ }
+ detail := decodeBody(t, detailResp)["data"].(map[string]any)
+ if detail["reasoning"] != "classifier failure: boom" {
+ t.Errorf("detail.reasoning = %v, want classifier failure cause", detail["reasoning"])
+ }
+
+ ch, dereg, err := hub.Register(sessID, "test-owner")
+ if err != nil {
+ t.Fatalf("Register: %v", err)
+ }
+ defer dereg()
+
+ resp := postJSON(t, srv, cookie, "/api/reviews/"+queueID+"/approve", map[string]any{})
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+
+ select {
+ case buf := <-ch:
+ var frame pb.ServerFrame
+ if err := proto.Unmarshal(buf, &frame); err != nil {
+ t.Fatalf("unmarshal frame: %v", err)
+ }
+ verdict := frame.GetVerdict()
+ if verdict == nil {
+ t.Fatalf("expected Verdict, got %T", frame.Frame)
+ }
+ if verdict.GetStatus() != pb.VerdictStatus_VERDICT_STATUS_ERROR {
+ t.Fatalf("status = %v, want ERROR", verdict.GetStatus())
+ }
+ if verdict.GetMadCode() != "" {
+ t.Fatalf("mad_code = %q, want empty", verdict.GetMadCode())
+ }
+ if verdict.GetHitl() == nil || !verdict.GetHitl().GetContinueExecution() {
+ t.Fatalf("expected approve to continue execution")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("subscriber never received the resolution frame")
+ }
+}
+
+func TestListReviewsFiltersByVerdictStatus(t *testing.T) {
+ srv, db, _, cookie := newTestServerWithHub(t)
+
+ const sessID = "sess-review-filter"
+ okEventID := uuid.NewString()
+ errorEventID := uuid.NewString()
+ okVerdictID := uuid.NewString()
+ errorVerdictID := uuid.NewString()
+ okQueueID := uuid.NewString()
+ errorQueueID := uuid.NewString()
+
+ if _, err := db.Exec(
+ `INSERT INTO events (id, session_id, agent_id, event_type, run_id, payload)
+ VALUES (?, ?, 'agent-h', 'llm', 'r-ok', '{}'),
+ (?, ?, 'agent-h', 'llm', 'r-error', '{}')`,
+ okEventID, sessID,
+ errorEventID, sessID,
+ ); err != nil {
+ t.Fatalf("seed events: %v", err)
+ }
+ if _, err := db.Exec(
+ `INSERT INTO verdicts (id, event_id, session_id, mad_code, classification, verdict_status)
+ VALUES (?, ?, ?, 'M3', 'block', 'ok'),
+ (?, ?, ?, '', 'error', 'error')`,
+ okVerdictID, okEventID, sessID,
+ errorVerdictID, errorEventID, sessID,
+ ); err != nil {
+ t.Fatalf("seed verdicts: %v", err)
+ }
+ if _, err := db.Exec(
+ `INSERT INTO hitl_queue (id, event_id, verdict_id, session_id, mad_code)
+ VALUES (?, ?, ?, ?, 'M3'),
+ (?, ?, ?, ?, '')`,
+ okQueueID, okEventID, okVerdictID, sessID,
+ errorQueueID, errorEventID, errorVerdictID, sessID,
+ ); err != nil {
+ t.Fatalf("seed hitl_queue: %v", err)
+ }
+
+ resp := getReq(t, srv, cookie, "/api/reviews?status=pending&verdict_status=error")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ data := decodeBody(t, resp)["data"].(map[string]any)
+ if int(data["total"].(float64)) != 1 {
+ t.Fatalf("total = %v, want 1", data["total"])
+ }
+ reviews := data["reviews"].([]any)
+ row := reviews[0].(map[string]any)
+ if row["id"] != errorQueueID || row["verdict_status"] != "error" {
+ t.Fatalf("filtered review = %v, want only classifier-error review %q", row, errorQueueID)
+ }
+
+ resp = getReq(t, srv, cookie, "/api/reviews?verdict_status=bogus")
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("invalid verdict_status status = %d, want 400", resp.StatusCode)
+ }
+}
+
func TestApproveReviewNoSubscriberStillResolves(t *testing.T) {
srv, db, _, cookie := newTestServerWithHub(t)
@@ -1072,6 +1221,66 @@ func TestEventsMinMADFilterUsesLatestVerdict(t *testing.T) {
}
}
+func TestEventsVerdictStatusFilterUsesLatestVerdict(t *testing.T) {
+ srv, db, _, cookie := newTestServerLoggedIn(t)
+
+ const sid = "sess-verdict-status"
+
+ eOK := uuid.NewString()
+ if _, err := db.Exec(
+ `INSERT INTO events (id, session_id, agent_id, event_type, run_id, payload)
+ VALUES (?, ?, 'agent-ok', 'tool', 'r1', '{}')`,
+ eOK, sid,
+ ); err != nil {
+ t.Fatalf("seed ok event: %v", err)
+ }
+ if _, err := db.Exec(
+ `INSERT INTO verdicts (id, event_id, session_id, mad_code, classification, verdict_status, created_at)
+ VALUES (?, ?, ?, '', 'error', 'error', datetime('now', '-2 seconds')),
+ (?, ?, ?, 'M0', 'benign', 'ok', datetime('now', '-1 seconds'))`,
+ uuid.NewString(), eOK, sid,
+ uuid.NewString(), eOK, sid,
+ ); err != nil {
+ t.Fatalf("seed ok verdicts: %v", err)
+ }
+
+ eError := uuid.NewString()
+ if _, err := db.Exec(
+ `INSERT INTO events (id, session_id, agent_id, event_type, run_id, payload)
+ VALUES (?, ?, 'agent-error', 'llm', 'r2', '{}')`,
+ eError, sid,
+ ); err != nil {
+ t.Fatalf("seed error event: %v", err)
+ }
+ if _, err := db.Exec(
+ `INSERT INTO verdicts (id, event_id, session_id, mad_code, classification, verdict_status, created_at)
+ VALUES (?, ?, ?, 'M0', 'benign', 'ok', datetime('now', '-2 seconds')),
+ (?, ?, ?, '', 'error', 'error', datetime('now', '-1 seconds'))`,
+ uuid.NewString(), eError, sid,
+ uuid.NewString(), eError, sid,
+ ); err != nil {
+ t.Fatalf("seed error verdicts: %v", err)
+ }
+
+ resp := getReq(t, srv, cookie, "/api/events?session_id="+sid+"&verdict_status=error")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ data := decodeBody(t, resp)["data"].(map[string]any)
+ if int(data["total"].(float64)) != 1 {
+ t.Errorf("verdict_status=error total = %v, want 1", data["total"])
+ }
+ events := data["events"].([]any)
+ if len(events) != 1 || events[0].(map[string]any)["id"] != eError {
+ t.Errorf("verdict_status=error events = %v, want only event %q", events, eError)
+ }
+
+ resp = getReq(t, srv, cookie, "/api/events?session_id="+sid+"&verdict_status=bogus")
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("invalid verdict_status status = %d, want 400", resp.StatusCode)
+ }
+}
+
// -----------------------------------------------------------------
// MCP servers
// -----------------------------------------------------------------
diff --git a/backend/internal/api/handlers_verdicts.go b/backend/internal/api/handlers_verdicts.go
index 3c75e57..273517c 100644
--- a/backend/internal/api/handlers_verdicts.go
+++ b/backend/internal/api/handlers_verdicts.go
@@ -17,6 +17,7 @@ type verdictResponse struct {
MADCode string `json:"mad_code"`
Classification string `json:"classification"`
VerdictStatus string `json:"verdict_status"`
+ Reasoning string `json:"reasoning,omitempty"`
LatencyMS *int64 `json:"latency_ms,omitempty"`
TokensUsed int32 `json:"tokens_used"`
CreatedAt string `json:"created_at"`
@@ -78,6 +79,7 @@ func verdictRowToResponse(r *store.VerdictListRow) verdictResponse {
MADCode: r.MADCode,
Classification: r.Classification,
VerdictStatus: r.VerdictStatus,
+ Reasoning: r.Reasoning,
LatencyMS: r.LatencyMS,
TokensUsed: r.TokensUsed,
CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z"),
diff --git a/backend/internal/notifications/discord_test.go b/backend/internal/notifications/discord_test.go
index c05a9ab..5530f39 100644
--- a/backend/internal/notifications/discord_test.go
+++ b/backend/internal/notifications/discord_test.go
@@ -5,13 +5,19 @@ package notifications
import (
"context"
+ "database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
+ "sync/atomic"
"testing"
"time"
+
+ "github.com/google/uuid"
+ "github.com/secureagentics/Adrian/backend/internal/store"
+ _ "modernc.org/sqlite"
)
func TestValidateDiscordWebhookURL(t *testing.T) {
@@ -120,6 +126,54 @@ func TestSendNonDiscordURLRejected(t *testing.T) {
}
}
+func TestDispatcherSkipsEmptyMADCode(t *testing.T) {
+ var posts int32
+ mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&posts, 1)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer mock.Close()
+
+ origHosts := allowedHosts
+ allowedHosts = []string{mock.URL + "/"}
+ defer func() { allowedHosts = origHosts }()
+
+ db, err := sql.Open("sqlite", "file:notifications?mode=memory&cache=shared")
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`
+CREATE TABLE webhooks (
+ id TEXT PRIMARY KEY,
+ platform TEXT NOT NULL DEFAULT 'discord',
+ webhook_url TEXT NOT NULL,
+ alert_type TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ installed_by_user_id TEXT,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+`); err != nil {
+ t.Fatalf("create webhooks: %v", err)
+ }
+ st := store.New(db)
+ if err := st.CreateWebhook(context.Background(), uuid.NewString(), mock.URL+"/api/webhooks/1/tok", "all", ""); err != nil {
+ t.Fatalf("create webhook: %v", err)
+ }
+
+ d := NewDispatcher(st, "https://dash.example")
+ d.fanout(context.Background(), VerdictNotification{
+ EventID: "ev-error",
+ SessionID: "sess-error",
+ MADCode: "",
+ Classification: "error",
+ })
+ if got := atomic.LoadInt32(&posts); got != 0 {
+ t.Fatalf("webhook posts = %d, want 0 for empty MAD code", got)
+ }
+}
+
func TestSendRespectsContextDeadline(t *testing.T) {
// Server that holds the response open longer than the client's
// context allows. The handler exits when r.Context() is cancelled
diff --git a/backend/internal/notifications/dispatcher.go b/backend/internal/notifications/dispatcher.go
index 99b56cc..692e4f1 100644
--- a/backend/internal/notifications/dispatcher.go
+++ b/backend/internal/notifications/dispatcher.go
@@ -69,7 +69,9 @@ func (d *Dispatcher) Run(ctx context.Context) {
// would mean state outside SQLite).
func (d *Dispatcher) fanout(ctx context.Context, vn VerdictNotification) {
if vn.MADCode == "" || strings.HasPrefix(vn.MADCode, "M0") {
- // Benign verdicts don't fan out; webhooks are for flagged events.
+ // Empty MAD codes (classifier errors) and M0 benign verdicts do
+ // not fan out; these webhooks are for real flagged MAD findings.
+ // Operational outage alerts should be a separate alert type.
return
}
hooks, err := d.store.ListWebhooks(ctx, true)
diff --git a/backend/internal/store/events.go b/backend/internal/store/events.go
index 3549725..07014ca 100644
--- a/backend/internal/store/events.go
+++ b/backend/internal/store/events.go
@@ -76,6 +76,9 @@ type EventFilters struct {
// Lets the dashboard surface flagged events that didn't trigger a
// HITL hold (post-execution tool pairs, tool_call-less LLM pairs).
MinMAD string
+ // VerdictStatus restricts to events whose latest verdict has this status.
+ // Accepts "ok" or "error"; empty = no filter.
+ VerdictStatus string
}
// InsertEvent persists one paired event and reports whether a new row
@@ -265,6 +268,13 @@ func eventsWhere(f EventFilters) (string, []any) {
args = append(args, t)
}
}
+ if f.VerdictStatus != "" {
+ parts = append(parts, "EXISTS (SELECT 1 FROM verdicts v "+
+ "WHERE v.event_id = e.id "+
+ "AND v.created_at = (SELECT max(v2.created_at) FROM verdicts v2 WHERE v2.event_id = e.id) "+
+ "AND v.verdict_status = ?)")
+ args = append(args, f.VerdictStatus)
+ }
return strings.Join(parts, " AND "), args
}
diff --git a/backend/internal/store/hitl.go b/backend/internal/store/hitl.go
index 7aa3019..abae609 100644
--- a/backend/internal/store/hitl.go
+++ b/backend/internal/store/hitl.go
@@ -7,6 +7,7 @@ import (
"context"
"database/sql"
"errors"
+ "strings"
"time"
"github.com/google/uuid"
@@ -47,28 +48,40 @@ func (s *Store) InsertHitlQueue(ctx context.Context, eventID, verdictID, session
return err
}
-// ListHitlQueue returns rows in the requested status (default 'pending'),
-// newest first, paginated.
-func (s *Store) ListHitlQueue(ctx context.Context, status string, perPage, offset int) ([]*HitlReview, int, error) {
+// ListHitlQueue returns rows in the requested review status (default
+// 'pending') and optional verdict status, newest first, paginated.
+func (s *Store) ListHitlQueue(ctx context.Context, status, verdictStatus string, perPage, offset int) ([]*HitlReview, int, error) {
if status == "" {
status = "pending"
}
+ where := []string{"q.status = ?"}
+ args := []any{status}
+ if verdictStatus != "" {
+ where = append(where, "COALESCE(v.verdict_status, 'ok') = ?")
+ args = append(args, verdictStatus)
+ }
+ whereSQL := strings.Join(where, " AND ")
+
var total int
if err := s.db.QueryRowContext(ctx,
- `SELECT count(*) FROM hitl_queue WHERE status = ?`, status,
+ `SELECT count(*)
+ FROM hitl_queue q
+ LEFT JOIN verdicts v ON v.id = q.verdict_id
+ WHERE `+whereSQL, args...,
).Scan(&total); err != nil {
return nil, 0, err
}
+ queryArgs := append(append([]any{}, args...), perPage, offset)
rows, err := s.db.QueryContext(ctx,
`SELECT q.id, q.event_id, COALESCE(q.verdict_id, ''), COALESCE(q.session_id, ''),
q.mad_code, COALESCE(v.verdict_status, 'ok'), q.status, COALESCE(q.reviewed_by, ''),
COALESCE(q.reviewed_at, ''), q.created_at
FROM hitl_queue q
LEFT JOIN verdicts v ON v.id = q.verdict_id
- WHERE q.status = ?
+ WHERE `+whereSQL+`
ORDER BY q.created_at DESC
LIMIT ? OFFSET ?`,
- status, perPage, offset)
+ queryArgs...)
if err != nil {
return nil, 0, err
}
diff --git a/backend/internal/store/stats.go b/backend/internal/store/stats.go
index 5d3ab47..abe40b3 100644
--- a/backend/internal/store/stats.go
+++ b/backend/internal/store/stats.go
@@ -10,11 +10,12 @@ import (
// Overview is the 24h summary the dashboard home renders.
type Overview struct {
- TotalEvents int
- FlaggedVerdicts int
- PendingReviews int
- ActiveAgents int
- VerdictsByMAD map[string]int
+ TotalEvents int
+ FlaggedVerdicts int
+ ClassifierErrors int
+ PendingReviews int
+ ActiveAgents int
+ VerdictsByMAD map[string]int
}
// ActivityBucket is one bin in the time-series response.
@@ -35,15 +36,25 @@ func (s *Store) StatsOverview(ctx context.Context) (*Overview, error) {
return nil, err
}
- // Flagged = anything other than M0/empty, i.e. an actual MAD code.
+ // Flagged = real non-M0 MAD findings. Classifier errors are tracked
+ // separately below so outages do not inflate security-finding totals.
if err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM verdicts
WHERE created_at >= datetime('now', ?)
+ AND verdict_status = 'ok'
AND mad_code != '' AND mad_code NOT LIKE 'M0%'`, window,
).Scan(&o.FlaggedVerdicts); err != nil {
return nil, err
}
+ if err := s.db.QueryRowContext(ctx,
+ `SELECT count(*) FROM verdicts
+ WHERE created_at >= datetime('now', ?)
+ AND verdict_status = 'error'`, window,
+ ).Scan(&o.ClassifierErrors); err != nil {
+ return nil, err
+ }
+
if err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM hitl_queue WHERE status = 'pending'`,
).Scan(&o.PendingReviews); err != nil {
@@ -59,7 +70,8 @@ func (s *Store) StatsOverview(ctx context.Context) (*Overview, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT
CASE
- WHEN mad_code LIKE 'M0%' OR mad_code = '' THEN 'M0'
+ WHEN verdict_status = 'error' THEN 'error'
+ WHEN mad_code LIKE 'M0%' THEN 'M0'
WHEN mad_code LIKE 'M2%' THEN 'M2'
WHEN mad_code LIKE 'M3%' THEN 'M3'
WHEN mad_code LIKE 'M4%' THEN 'M4'
diff --git a/backend/internal/store/verdicts.go b/backend/internal/store/verdicts.go
index 027490a..beaa21e 100644
--- a/backend/internal/store/verdicts.go
+++ b/backend/internal/store/verdicts.go
@@ -33,6 +33,7 @@ type VerdictListRow struct {
MADCode string
Classification string
VerdictStatus string
+ Reasoning string
LatencyMS *int64
TokensUsed int32
CreatedAt time.Time
@@ -76,7 +77,7 @@ func (s *Store) ListVerdicts(ctx context.Context, f VerdictFilters, perPage, off
args = append(args, perPage, offset)
rows, err := s.db.QueryContext(ctx,
`SELECT id, event_id, session_id, mad_code, classification, verdict_status,
- latency_ms, tokens_used, created_at
+ COALESCE(reasoning, ''), latency_ms, tokens_used, created_at
FROM verdicts
WHERE `+where+`
ORDER BY created_at DESC
@@ -92,7 +93,7 @@ func (s *Store) ListVerdicts(ctx context.Context, f VerdictFilters, perPage, off
var latency sql.NullInt64
var createdAt string
if err := rows.Scan(&r.ID, &r.EventID, &r.SessionID, &r.MADCode, &r.Classification, &r.VerdictStatus,
- &latency, &r.TokensUsed, &createdAt); err != nil {
+ &r.Reasoning, &latency, &r.TokensUsed, &createdAt); err != nil {
return nil, 0, err
}
if latency.Valid {
@@ -109,14 +110,14 @@ func (s *Store) ListVerdicts(ctx context.Context, f VerdictFilters, perPage, off
func (s *Store) GetVerdictByEventID(ctx context.Context, eventID string) (*VerdictListRow, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, event_id, session_id, mad_code, classification, verdict_status,
- latency_ms, tokens_used, created_at
+ COALESCE(reasoning, ''), latency_ms, tokens_used, created_at
FROM verdicts WHERE event_id = ?
ORDER BY created_at DESC LIMIT 1`, eventID)
r := &VerdictListRow{}
var latency sql.NullInt64
var createdAt string
if err := row.Scan(&r.ID, &r.EventID, &r.SessionID, &r.MADCode, &r.Classification, &r.VerdictStatus,
- &latency, &r.TokensUsed, &createdAt); err != nil {
+ &r.Reasoning, &latency, &r.TokensUsed, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go
index a40706b..4b5f5a2 100644
--- a/backend/internal/ws/handler.go
+++ b/backend/internal/ws/handler.go
@@ -303,6 +303,11 @@ func persistAndClassify(ctx context.Context, sess *session, st *store.Store, cla
}
verdict, err := classifier.Classify(ctx, ev, agentProfileID)
if err != nil {
+ if ctx.Err() != nil {
+ slog.InfoContext(ctx, "ws.classify_cancelled",
+ "error", err, "event_id", ev.EventId)
+ return nil
+ }
slog.WarnContext(ctx, "ws.classifier_failure",
"error", err, "event_id", ev.EventId)
reasoning := "classifier failure: " + err.Error()
@@ -354,6 +359,10 @@ func persistAndClassify(ctx context.Context, sess *session, st *store.Store, cla
}
func dispatchVerdict(ctx context.Context, sess *session, st *store.Store, hub *Hub, ev *pb.PairedEvent, snap *pb.PolicySnapshot, verdictID, madCode, verdictStatus string) error {
+ if verdictStatus == "error" {
+ return dispatchErrorVerdict(ctx, sess, st, hub, ev, snap, verdictID, madCode)
+ }
+
// Mode-gated dispatch:
// alert: persist verdict, do NOT notify the SDK (dashboard-only).
// hitl + in-scope + actionable: persist + queue for human review,
@@ -361,7 +370,7 @@ func dispatchVerdict(ctx context.Context, sess *session, st *store.Store, hub *H
// hitl + in-scope + non-actionable: forward (review would be a
// no-op for the operator since the SDK never blocks on it).
// hitl + out-of-scope: forward (no review queued for this code).
- // block: forward all verdicts; SDK is the enforcement point.
+ // block: forward all OK verdicts; SDK is the enforcement point.
inScope := shouldFanOut(snap, madCode)
switch snap.GetMode() {
case pb.Mode_MODE_ALERT:
@@ -391,6 +400,38 @@ func dispatchVerdict(ctx context.Context, sess *session, st *store.Store, hub *H
return nil
}
+ publishVerdict(ctx, sess, hub, ev, snap, madCode, verdictStatus)
+ return nil
+}
+
+func dispatchErrorVerdict(ctx context.Context, sess *session, st *store.Store, hub *Hub, ev *pb.PairedEvent, snap *pb.PolicySnapshot, verdictID, madCode string) error {
+ switch snap.GetMode() {
+ case pb.Mode_MODE_ALERT:
+ return nil
+ case pb.Mode_MODE_BLOCK:
+ publishVerdict(ctx, sess, hub, ev, snap, madCode, "error")
+ return nil
+ case pb.Mode_MODE_HITL:
+ if snap.GetFailClosedOnClassifierError() && isActionable(ev) {
+ if err := st.InsertHitlQueue(ctx, ev.EventId, verdictID, sess.sessionID, madCode); err != nil {
+ slog.ErrorContext(ctx, "hitl.insert_failed_fallback_publish",
+ "error", err, "event_id", ev.EventId, "verdict_id", verdictID)
+ publishVerdict(ctx, sess, hub, ev, snap, madCode, "error")
+ }
+ return nil
+ }
+ publishVerdict(ctx, sess, hub, ev, snap, madCode, "error")
+ return nil
+ default:
+ slog.WarnContext(ctx, "ws.unknown_mode_dropping_verdict",
+ "mode", snap.GetMode().String(), "event_id", ev.EventId)
+ return nil
+ }
+}
+
+func publishVerdict(ctx context.Context, sess *session, hub *Hub, ev *pb.PairedEvent, snap *pb.PolicySnapshot, madCode, verdictStatus string) {
+ warnOldSDKClassifierErrorCompatibility(ctx, sess, ev, snap, verdictStatus)
+
out := &pb.ServerFrame{
Frame: &pb.ServerFrame_Verdict{
Verdict: &pb.Verdict{
@@ -406,7 +447,28 @@ func dispatchVerdict(ctx context.Context, sess *session, st *store.Store, hub *H
slog.WarnContext(ctx, "ws.publish_dropped",
"event_id", ev.EventId, "session_id", sess.sessionID)
}
- return nil
+}
+
+func warnOldSDKClassifierErrorCompatibility(ctx context.Context, sess *session, ev *pb.PairedEvent, snap *pb.PolicySnapshot, verdictStatus string) {
+ if verdictStatus != "error" || !snap.GetFailClosedOnClassifierError() || sess.warnedClassifierErrorCompatibility {
+ return
+ }
+ sess.warnedClassifierErrorCompatibility = true
+ slog.WarnContext(ctx, "ws.classifier_error_fail_closed_requires_updated_sdk",
+ "event_id", ev.EventId,
+ "session_id", sess.sessionID,
+ "message", "old SDKs ignore classifier-error status and policy fields, so fail-closed enforcement requires the updated SDK")
+}
+
+func verdictStatusProto(status string) pb.VerdictStatus {
+ switch status {
+ case "error":
+ return pb.VerdictStatus_VERDICT_STATUS_ERROR
+ case "ok":
+ return pb.VerdictStatus_VERDICT_STATUS_OK
+ default:
+ return pb.VerdictStatus_VERDICT_STATUS_UNSPECIFIED
+ }
}
func verdictStatusProto(status string) pb.VerdictStatus {
diff --git a/backend/internal/ws/handler_test.go b/backend/internal/ws/handler_test.go
index cf70d9c..473600a 100644
--- a/backend/internal/ws/handler_test.go
+++ b/backend/internal/ws/handler_test.go
@@ -254,6 +254,101 @@ func TestClassifierFailurePersistsAndPublishesErrorVerdict(t *testing.T) {
}
}
+func TestClassifierFailureAlertPersistsWithoutPublish(t *testing.T) {
+ db, conn := classifierFailureConn(t, "alert", false)
+
+ eventID := uuid.NewString()
+ if err := sendPairedEvent(conn, classifierFailureToolEvent(eventID, "classifier-failure-alert")); err != nil {
+ t.Fatalf("send paired_batch: %v", err)
+ }
+
+ if err := expectNoServerFrame(conn, 250*time.Millisecond); err == nil {
+ t.Fatal("expected no SDK verdict in alert mode")
+ }
+ assertStoredErrorVerdict(t, db, eventID)
+}
+
+func TestClassifierFailureHitlFailClosedQueuesActionable(t *testing.T) {
+ db, conn := classifierFailureConn(t, "hitl", true)
+
+ eventID := uuid.NewString()
+ if err := sendPairedEvent(conn, classifierFailureActionableEvent(eventID, "classifier-failure-hitl")); err != nil {
+ t.Fatalf("send paired_batch: %v", err)
+ }
+
+ if err := expectNoServerFrame(conn, 250*time.Millisecond); err == nil {
+ t.Fatal("expected actionable fail-closed ERROR verdict to be held for HITL")
+ }
+ assertStoredErrorVerdict(t, db, eventID)
+
+ var queued int
+ if err := db.QueryRow(
+ `SELECT count(*) FROM hitl_queue h
+ JOIN verdicts v ON v.id = h.verdict_id
+ WHERE h.event_id = ? AND h.mad_code = '' AND v.verdict_status = 'error'`,
+ eventID,
+ ).Scan(&queued); err != nil {
+ t.Fatalf("query hitl_queue: %v", err)
+ }
+ if queued != 1 {
+ t.Fatalf("queued error reviews = %d, want 1", queued)
+ }
+}
+
+func TestClassifierFailureHitlFailClosedNonActionablePublishes(t *testing.T) {
+ db, conn := classifierFailureConn(t, "hitl", true)
+
+ eventID := uuid.NewString()
+ if err := sendPairedEvent(conn, classifierFailureToolEvent(eventID, "classifier-failure-hitl-nonactionable")); err != nil {
+ t.Fatalf("send paired_batch: %v", err)
+ }
+
+ frame, err := readServerFrame(conn)
+ if err != nil {
+ t.Fatalf("read verdict: %v", err)
+ }
+ if got := frame.GetVerdict().GetStatus(); got != bpb.VerdictStatus_VERDICT_STATUS_ERROR {
+ t.Fatalf("pushed status = %v, want ERROR", got)
+ }
+ assertStoredErrorVerdict(t, db, eventID)
+
+ var queued int
+ if err := db.QueryRow(`SELECT count(*) FROM hitl_queue WHERE event_id = ?`, eventID).Scan(&queued); err != nil {
+ t.Fatalf("query hitl_queue: %v", err)
+ }
+ if queued != 0 {
+ t.Fatalf("queued reviews = %d, want 0", queued)
+ }
+}
+
+func TestClassifierFailureHitlQueueFailureFallsBackToPublish(t *testing.T) {
+ db, conn := classifierFailureConn(t, "hitl", true)
+ if _, err := db.Exec(`
+CREATE TRIGGER fail_hitl_insert
+BEFORE INSERT ON hitl_queue
+BEGIN
+ SELECT RAISE(FAIL, 'forced hitl insert failure');
+END;
+`); err != nil {
+ t.Fatalf("create hitl failure trigger: %v", err)
+ }
+
+ eventID := uuid.NewString()
+ if err := sendPairedEvent(conn, classifierFailureActionableEvent(eventID, "classifier-failure-hitl-fallback")); err != nil {
+ t.Fatalf("send paired_batch: %v", err)
+ }
+
+ frame, err := readServerFrame(conn)
+ if err != nil {
+ t.Fatalf("read verdict: %v", err)
+ }
+ verdict := frame.GetVerdict()
+ if verdict.GetStatus() != bpb.VerdictStatus_VERDICT_STATUS_ERROR || verdict.GetMadCode() != "" {
+ t.Fatalf("pushed verdict = (%q, %v), want ('', ERROR)", verdict.GetMadCode(), verdict.GetStatus())
+ }
+ assertStoredErrorVerdict(t, db, eventID)
+}
+
func TestDuplicateEventRetryKeepsWSOpen(t *testing.T) {
db := openInMemoryDB(t)
t.Cleanup(func() { _ = db.Close() })
@@ -648,6 +743,120 @@ type fakeClassifier struct {
calls *int32
}
+func classifierFailureConn(t *testing.T, mode string, failClosed bool) (*sql.DB, *websocket.Conn) {
+ t.Helper()
+
+ db := openInMemoryDB(t)
+ t.Cleanup(func() { _ = db.Close() })
+
+ st := store.New(db)
+ plaintextKey := "adr_local_test_key_classifier_failure_" + uuid.NewString()
+ keyHash := sha256Hex(plaintextKey)
+ insertAPIKey(t, db, keyHash)
+
+ failClosedInt := 0
+ if failClosed {
+ failClosedInt = 1
+ }
+ if _, err := db.Exec(
+ `UPDATE policies SET mode = ?, fail_closed_on_classifier_error = ? WHERE id = 1`,
+ mode, failClosedInt,
+ ); err != nil {
+ t.Fatalf("set policy: %v", err)
+ }
+
+ llm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "classifier exploded", http.StatusInternalServerError)
+ }))
+ t.Cleanup(llm.Close)
+ classifier := engine.NewHTTPClient(llm.URL, "test-key", "test-model", nil, nil)
+
+ mux := http.NewServeMux()
+ mux.Handle("/ws", ws.AuthMiddleware(st)(ws.NewHandler(st, classifier, ws.NewHub(), nil, nil)))
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
+ header := http.Header{"Authorization": {"Bearer " + plaintextKey}}
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
+ if err != nil {
+ t.Fatalf("dial: %v", err)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ if err := writeProto(conn, &bpb.ClientFrame{
+ Frame: &bpb.ClientFrame_Login{Login: &bpb.SessionLogin{
+ SessionId: "classifier-failure-sess-" + uuid.NewString(), SchemaVersion: 2,
+ }},
+ }); err != nil {
+ t.Fatalf("send login: %v", err)
+ }
+ if _, err := readServerFrame(conn); err != nil {
+ t.Fatalf("read login_ack: %v", err)
+ }
+ return db, conn
+}
+
+func classifierFailureToolEvent(eventID, sessionID string) *bpb.PairedEvent {
+ return &bpb.PairedEvent{
+ EventId: eventID, SessionId: sessionID,
+ RunId: "run-classifier-failure",
+ PairType: bpb.PairType_PAIR_TYPE_TOOL,
+ Agent: &bpb.AgentContext{AgentId: "failure-agent"},
+ Data: &bpb.PairedEvent_Tool{Tool: &bpb.ToolPairData{
+ ToolName: "noop", ToolCallId: "tc-classifier-failure", Input: "{}", Output: "ok",
+ }},
+ }
+}
+
+func classifierFailureActionableEvent(eventID, sessionID string) *bpb.PairedEvent {
+ return &bpb.PairedEvent{
+ EventId: eventID, SessionId: sessionID,
+ RunId: "run-classifier-failure",
+ PairType: bpb.PairType_PAIR_TYPE_LLM,
+ Agent: &bpb.AgentContext{AgentId: "failure-agent"},
+ Data: &bpb.PairedEvent_Llm{Llm: &bpb.LlmPairData{
+ Model: "test-model",
+ Output: "calling tool",
+ ToolCalls: []*bpb.ToolCall{{
+ Name: "noop", Id: "tc-classifier-failure", Args: "{}",
+ }},
+ }},
+ }
+}
+
+func sendPairedEvent(conn *websocket.Conn, ev *bpb.PairedEvent) error {
+ return writeProto(conn, &bpb.ClientFrame{
+ Frame: &bpb.ClientFrame_PairedBatch{PairedBatch: &bpb.PairedEventBatch{
+ Events: []*bpb.PairedEvent{ev},
+ }},
+ })
+}
+
+func expectNoServerFrame(conn *websocket.Conn, timeout time.Duration) error {
+ if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
+ return err
+ }
+ _, _, err := conn.ReadMessage()
+ _ = conn.SetReadDeadline(time.Time{})
+ return err
+}
+
+func assertStoredErrorVerdict(t *testing.T, db *sql.DB, eventID string) {
+ t.Helper()
+ var madCode, classification, verdictStatus string
+ if err := db.QueryRow(
+ `SELECT mad_code, classification, verdict_status FROM verdicts WHERE event_id = ?`,
+ eventID,
+ ).Scan(&madCode, &classification, &verdictStatus); err != nil {
+ t.Fatalf("query verdict: %v", err)
+ }
+ if madCode != "" || classification != "error" || verdictStatus != "error" {
+ t.Fatalf("stored verdict = (%q, %q, %q), want ('', error, error)",
+ madCode, classification, verdictStatus)
+ }
+}
+
func (f *fakeClassifier) Classify(_ context.Context, _ *bpb.PairedEvent, _ string) (*engine.Verdict, error) {
if f.calls != nil {
atomic.AddInt32(f.calls, 1)
@@ -736,7 +945,8 @@ func statusOrZero(r *http.Response) int {
}
// testSchema is the minimum subset of 001_initial_schema.sql the WS
-// handler exercises (api_keys, policies, events, verdicts, mcp_servers).
+// handler exercises (api_keys, policies, events, verdicts, mcp_servers,
+// hitl_queue).
// Embedding the full migration file here would couple the test to the
// migration's evolution.
const testSchema = `
@@ -798,4 +1008,15 @@ CREATE TABLE agents (
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
metadata TEXT NOT NULL DEFAULT '{}'
);
+CREATE TABLE hitl_queue (
+ id TEXT PRIMARY KEY,
+ event_id TEXT NOT NULL UNIQUE,
+ verdict_id TEXT,
+ session_id TEXT,
+ mad_code TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ reviewed_by TEXT,
+ reviewed_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
`
diff --git a/backend/internal/ws/helpers.go b/backend/internal/ws/helpers.go
index 0b7e42b..4f4d283 100644
--- a/backend/internal/ws/helpers.go
+++ b/backend/internal/ws/helpers.go
@@ -45,8 +45,8 @@ func isActionable(ev *pb.PairedEvent) bool {
return llm != nil && len(llm.ToolCalls) > 0
}
-// shouldFanOut decides whether a verdict's MAD code is in scope for
-// the active policy. False for codes outside the M0/M2/M3/M4 set
+// shouldFanOut decides whether an OK verdict's MAD code is in scope
+// for the active policy. False for codes outside the M0/M2/M3/M4 set
// (defensive: an unrecognised code drops rather than panics) and for
// MAD families whose policy_mX flag is unset.
//
diff --git a/backend/internal/ws/session.go b/backend/internal/ws/session.go
index cb251a3..4409e6c 100644
--- a/backend/internal/ws/session.go
+++ b/backend/internal/ws/session.go
@@ -15,6 +15,8 @@ type session struct {
llmProvider string
llmModel string
loggedIn bool
+
+ warnedClassifierErrorCompatibility bool
}
// agentProfileID returns the bound agent_profile_id (or nil if the
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5e0228f..2c5ee14 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -22,8 +22,9 @@
| HTTP POST to ADRIAN_LLM_URL (OpenAI |
| compatible chat-completions), strip |
|
+ {verdict.reasoning} +
+ )} + {verdict && !isClassifierErrorVerdict(verdict) && verdict.mad_code !== 'M0' && (Nothing waiting on you
- When policy mode is HITL and a flagged verdict lands in scope, the SDK pauses and the event appears here. + When policy mode is HITL and a flagged verdict or fail-closed classifier error lands in scope, the SDK pauses and the event appears here.
Classifier error
++ The classifier did not return a MAD code. Approving resumes the paused SDK action; rejecting returns a blocked tool response. +
+ {detail.reasoning && ( ++ {detail.reasoning} +
+ )} +Event payload
diff --git a/frontend/app/(dashboard)/sessions/[session_id]/page.tsx b/frontend/app/(dashboard)/sessions/[session_id]/page.tsx index 806f519..0898bfc 100644 --- a/frontend/app/(dashboard)/sessions/[session_id]/page.tsx +++ b/frontend/app/(dashboard)/sessions/[session_id]/page.tsx @@ -5,12 +5,13 @@ import { useParams } from 'next/navigation' import { api } from '@/lib/api' import { Badge } from '@/components/badge' import { JsonBlock } from '@/components/json-block' -import { madBadgeColor, timeAgo } from '@/lib/utils' +import { verdictBadgeColor, verdictBadgeLabel, timeAgo } from '@/lib/utils' type Verdict = { id: string mad_code: string classification: string + verdict_status: string } type Entry = { @@ -92,7 +93,7 @@ export default function SessionTimelinePage() {Verdict
+ Classifier failure handling +
++ Older SDK versions ignore this flag. Update agents to the SDK version + shipped with this dashboard before relying on fail-closed enforcement. +
+