Audit guardrail refusals as status: "refused" — design
Date: 2026-07-09
Status: spec
Source: integration prompt INTEGRATION-chatbot-refused-audit-status.md.
Problem
The observatory's probing rule (sec_error_probing) watches per-actor refusal
rates in ai_calls, but the chatbot never tells the audit trail a refusal
happened. When the L1 input guard blocks or the L2 planner returns refuse=true,
the only audit event for that turn is the planning LLM call — logged status:ok.
So a real jailbreak attempt is structurally invisible. The observatory side is
already fixed + deployed (2026-07-09): its rule counts status NOT IN
('ok','completed') rows whose error_code is not a gateway infra code. The
moment this repo emits status:"refused", those rows light up the rule with zero
observatory changes.
Design
One emit choke point
Every structured refusal (L1 PII/entity guard, no-tools, L2 refuse=true,
unparseable plan) flows through plan() → the isRefusal(p) branch in
orchestrate.ts. A single fire-and-forget emit there covers them all, placed
before streaming the refusal copy (but never able to delay/break it).
Refusal category → error_code
Refusal (@ahu/orchestrator-types) gains an optional machine-readable code
(RefusalCode union), set at each creation site:
| Path | Code |
|---|---|
| L1 PII pattern | GUARDRAIL_INPUT_PII |
| L1 entity/off-topic/custom block | GUARDRAIL_INPUT_ENTITY |
L2 planner refuse=true |
GUARDRAIL_PLAN_REFUSED |
| No active tools | NO_TOOLS |
| Unparseable plan | PLAN_UNPARSEABLE |
GUARDRAIL_* is a stable namespace (none match the infra UPSTREAM_*/HTTP_*
prefixes, so all count as probing evidence). NO_TOOLS/PLAN_UNPARSEABLE are
honesty cases — refusal-shaped config/parse issues, audited distinctly.
Emitter (apps/public-web/src/lib/audit/emit.ts)
New module — public-web-only for now, so it lives in the app (moves to a package
only when a second app needs it). Mirrors the anon-limits lazy-singleton
fail-open ioredis pattern, but on a separate env var / instance:
AUDIT_REDIS_URL— no default; unset ⇒ no-op (the §6 dormancy
contract).AUDIT_STREAM— defaultahu.ai.audit.- Fire-and-forget (§5):
lazyConnect,maxRetriesPerRequest:1, no retry
strategy,commandTimeout:500, every failure swallowed after a rate-limited
console.warn. Never throws. XADD <stream> * json <event-json>— schema-v1 event;event_id= uuidv7
(never blank — the ingester treats blank as poison). Body columns omitted;
the user's message text never enters the event (metadata only).
Event fields: engine:"ahu-chatbot", surface = turn surface, user_id = the
turn's X-User-Id (req.sessionId), trace_id = the turn's X-Request-Id
(turnTraceId — joins the refusal to the turn's other calls), status:"refused",
error_code = the code, operation:"chat", traffic_class:"interactive",
total_ms = turn start → refusal, config_version = policy version.
Deploy wiring (dormant)
The platform audit Redis is ahu-platform-redis on the external
ahu-platform_default network (confirmed: it holds ahu.ai.audit). public-web
joins that network in compose.public.yaml so the host resolves once flipped,
but AUDIT_REDIS_URL stays unset by default → ships dormant. Flip is a
deploy-time decision for Efran.
Out of scope (flagged)
The staff/internal native-Agno path has no structured refusal signal (refusals
there are prompt-driven text) — it cannot emit refused events yet, and we will
NOT string-match agent output. Follow-up.
Tests (TDD)
Refusal code per path; buildRefusalEvent schema + no message text; emit XADDs
once to the right stream with correct fields; dormant when unset; fire-and-forget
when Redis throws; non-refusal turns emit nothing.
Acceptance
- [ ] Every structured refusal emits one schema-v1
status:"refused"event with
the rightGUARDRAIL_*/NO_TOOLS/PLAN_UNPARSEABLEcode;trace_id=
turn X-Request-Id; no message content. - [ ] Unset
AUDIT_REDIS_URL⇒ byte-identical; dead Redis ⇒ user stream intact. - [ ]
pnpm checkgreen; no deploy without go-ahead.