Integration task (ahu-ai-chatbot) — audit guardrail refusals as status: "refused"
Paste everything below into a Claude Code session running inside the
ahu-ai-chatbotrepo.
Self-contained: context, the exact emit site, the event schema, dormancy rules, tests, and this repo's workflow gates.
Background — refusals are invisible to the audit trail today
The AHU AI Observatory's security rules watch for guardrail probing (jailbreak attempts) by looking at per-actor refusal rates in ai_calls. But the chatbot never tells the audit trail that a refusal happened:
// apps/public-web/src/lib/orchestrator/orchestrate.ts:201
if (isRefusal(p)) {
yield await emitContent(p.reason); // refusal copy streamed to the user
yield await emitDone();
return; // ← no audit signal of any kind
}
When the L1 input guard blocks a PII/entity query, or the L2 planner returns refuse=true, the user sees a refusal — but the only audit event for that turn is the planning LLM call, which succeeded and is logged status: ok. The frontend's refused: true flag (packages/streams/src/chat-types.ts:38) is UI-only; it never reaches the audit pipeline. Net effect: the observatory's probing rule (sec_error_probing) structurally cannot see a real jailbreak attempt.
The observatory side is already fixed and deployed (2026-07-09): its probing rule now counts status NOT IN ('ok','completed') rows whose error_code is not a gateway infra code (UPSTREAM_*, AZURE_DI_*, GPUSERVER_*, HTTP_*). So the moment this repo emits status: "refused" events, they light up the probing rule with zero further observatory changes.
The contract (CONVENTIONS.md §4–§6 — read it first: ahu-gpu-manager/docs/CONVENTIONS.md)
Engines may emit audit events directly (the "SDK pattern", §4.5): one JSON event per AI interaction onto Redis Stream ahu.ai.audit, fire-and-forget (§5: "never block or fail the user request because auditing is down"), and dormant until env flip (§6: unset env = byte-identical current behavior).
Wire format
XADD ahu.ai.audit * json '<event-json>' — a single stream field named json holding the event. The observatory ingester treats a missing/blank event_id as poison, so always set it.
Event JSON for a refused turn
Field names must match exactly (schema v1, verified against the observatory's internal/store/calls.go):
{
"event_id": "<uuidv7 — unique per event, never reused>",
"schema_ver": 1,
"ts": "<ISO-8601 / RFC3339Nano UTC, e.g. 2026-07-09T02:00:00.123Z>",
"engine": "ahu-chatbot",
"surface": "public", // this turn's surface
"user_id": "<same value sent as X-User-Id for this turn>",
"trace_id": "<the turn's X-Request-Id — joins the refusal to the turn's other calls>",
"upstream": "", // no model was consulted for the refusal itself
"model": "",
"upstream_class": "",
"traffic_class": "interactive",
"operation": "chat",
"status": "refused", // ← the whole point
"error_code": "<refusal category — see below>",
"queue_ms": 0,
"upstream_ms": 0,
"total_ms": <ms from turn start to refusal>,
"tokens_in": null,
"tokens_out": null,
"doc_hash": "",
"pages": null,
"job_id": "",
"parent_job_id": "",
"config_version": "<policy version if available, else ''>"
}
Omit request_body_zst/response_body_zst entirely. Never put the user's message text in the event — refusal events are metadata only.
error_code = refusal category
Refusal (packages/orchestrator-types/src/plan.ts) today carries only kind + reason (display copy). Add an optional machine-readable code so the audit event can say which guardrail fired:
| Path | Code |
|---|---|
L1 input guard, PII pattern (guards/input-patterns.ts via REFUSAL_PII) |
GUARDRAIL_INPUT_PII |
L1 input guard, entity pattern (REFUSAL_ENTITY) |
GUARDRAIL_INPUT_ENTITY |
L2 planner refuse=true (steps/plan.ts:81) |
GUARDRAIL_PLAN_REFUSED |
No active tools (steps/plan.ts:64) |
NO_TOOLS |
Unparseable plan fallback (steps/plan.ts:99) |
PLAN_UNPARSEABLE |
Prefix guardrail-driven ones with GUARDRAIL_ — a stable namespace the observatory can target later. (None of these match the infra prefixes, so they all count as probing evidence — correct.) NO_TOOLS/PLAN_UNPARSEABLE are honesty cases: they're refusal-shaped but config/parse issues; still worth auditing distinctly.
Where to emit
One choke point: the isRefusal(p) branch in apps/public-web/src/lib/orchestrator/orchestrate.ts (~line 201). Every refusal path (L1 guard, no-tools, L2 refuse, unparseable plan) flows through plan() → this branch, so a single emit call covers them all. Emit before streaming the refusal copy, but never let emit failure affect the stream (wrap in try/catch or .catch(() => {}) — fire-and-forget).
Out of scope, flag as follow-up: the staff/internal native-Agno path has no structured refusal signal (refusals there are prompt-driven text), so it cannot emit refused events yet. Leave a note in the completion docs; do not attempt to detect refusals by string-matching agent output.
The emitter module
New shared-ish module — but note it's only used by public-web today, so per the boundary rule it can live at apps/public-web/src/lib/audit/emit.ts (move to a packages/@ahu/* package only when a second app needs it).
- Reuse the
ioredisdependency and the lazy-singleton/fail-open pattern fromapps/public-web/src/lib/anon/redis.ts— but a separate client on a separate env var. The anon-limits Redis and the platform audit Redis are different instances; do not shareREDIS_URL. - Env (platform-owned prefix per §6):
AUDIT_REDIS_URL(no default — unset ⇒ emitter is a no-op, the dormancy contract) andAUDIT_STREAM(defaultahu.ai.audit). - Fire-and-forget hard rules:
lazyConnect: true,maxRetriesPerRequest: 1, no retry strategy, a short command budget (≤500 ms —XADDis sub-ms when healthy), and every failure swallowed after a singleconsole.warn(rate-limit the warn so a dead Redis doesn't spam logs on every refusal). - Update
infra/env/*.env.examplewith the new vars + comments. Deploy note for compose: the platform Redis isredis://ahu-platform-redis:6379/0on the externalahu-platform_defaultnetwork and is not host-published — thepublic-webservice must join that network (same approach as the observatory dashboard's compose) for the URL to resolve. Wire the network join ininfra/but leaveAUDIT_REDIS_URLunset by default so the feature ships dormant; flipping it on is a deploy-time decision for Efran.
Tests (TDD — this repo's mandate)
Refusalgains optionalcode: existing constructors updated; type tests if present.- Each refusal path sets its expected
code(L1 PII, L1 entity, plan-refuse, no-tools, unparseable). - Orchestrate + refusal +
AUDIT_REDIS_URLset (mock/stub redis): exactly oneXADDto the right stream; thejsonfield parses;status === "refused",error_codecorrect,trace_idequals the turn'sX-Request-Id,engine === "ahu-chatbot", non-blank uniqueevent_id, no message text anywhere in the payload. AUDIT_REDIS_URLunset: zero redis interaction (dormancy proof).- Redis throwing/timing out: the user still receives the full refusal stream (fire-and-forget proof).
- Non-refusal turns: no event emitted from this path (the gateway already audits the model calls; don't double-count).
Workflow & gates (CLAUDE.md)
- Feature-sized (new module + type change + env) → spec → plan → completion notes in
docs/superpowers/{specs,plans}. pnpm checkgreen before ship. Deploy only viabuild-and-ship.sh→deploy-staging.sh, and only with Efran's explicit go-ahead.- Respect the app boundary (
public-web↔internal-webnever import each other).
Verification (after an authorized staging deploy + env flip)
- On the public Tanya page, ask something the L1 guard blocks (e.g. a query about a specific individual/NIK). Confirm the normal refusal copy streams to the browser (UX unchanged).
- On the GPU host:
docker exec -it <platform-redis> redis-cli XREVRANGE ahu.ai.audit + - COUNT 3→ the refusal event is on the stream. - Observatory DB:
SELECT ts, status, error_code, trace_id FROM ai_calls WHERE engine='ahu-chatbot' AND status='refused' ORDER BY ts DESC LIMIT 5;→ the row landed,trace_idmatches the turn. - Repeat the blocked query ~15+ times in one session, then
GET /api/security-warnings(operator token) →sec_error_probingfires for that actor, citingprobe_calls. That's the end-to-end proof the probing rule finally sees real guardrail pressure.
Acceptance criteria
- [ ] Every structured refusal path emits exactly one schema-v1 event with
status: "refused"and aGUARDRAIL_*/NO_TOOLS/PLAN_UNPARSEABLEcode. - [ ]
trace_id= the turn'sX-Request-Id;user_id= the turn'sX-User-Idvalue; no message content in the event. - [ ] Unset
AUDIT_REDIS_URL⇒ byte-identical behavior (dormant); dead Redis ⇒ user stream unaffected. - [ ] Tests above green;
pnpm checkgreen; spec/plan/completion notes present; no deploy without explicit go-ahead.