think
16px
820px

Integration task (ahu-ai-chatbot) — audit guardrail refusals as status: "refused"

Paste everything below into a Claude Code session running inside the ahu-ai-chatbot repo.
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 ioredis dependency and the lazy-singleton/fail-open pattern from apps/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 share REDIS_URL.
  • Env (platform-owned prefix per §6): AUDIT_REDIS_URL (no default — unset ⇒ emitter is a no-op, the dormancy contract) and AUDIT_STREAM (default ahu.ai.audit).
  • Fire-and-forget hard rules: lazyConnect: true, maxRetriesPerRequest: 1, no retry strategy, a short command budget (≤500 ms — XADD is sub-ms when healthy), and every failure swallowed after a single console.warn (rate-limit the warn so a dead Redis doesn't spam logs on every refusal).
  • Update infra/env/*.env.example with the new vars + comments. Deploy note for compose: the platform Redis is redis://ahu-platform-redis:6379/0 on the external ahu-platform_default network and is not host-published — the public-web service must join that network (same approach as the observatory dashboard's compose) for the URL to resolve. Wire the network join in infra/ but leave AUDIT_REDIS_URL unset by default so the feature ships dormant; flipping it on is a deploy-time decision for Efran.

Tests (TDD — this repo's mandate)

  • Refusal gains optional code: 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_URL set (mock/stub redis): exactly one XADD to the right stream; the json field parses; status === "refused", error_code correct, trace_id equals the turn's X-Request-Id, engine === "ahu-chatbot", non-blank unique event_id, no message text anywhere in the payload.
  • AUDIT_REDIS_URL unset: 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 check green before ship. Deploy only via build-and-ship.shdeploy-staging.sh, and only with Efran's explicit go-ahead.
  • Respect the app boundary (public-webinternal-web never import each other).

Verification (after an authorized staging deploy + env flip)

  1. 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).
  2. On the GPU host: docker exec -it <platform-redis> redis-cli XREVRANGE ahu.ai.audit + - COUNT 3 → the refusal event is on the stream.
  3. 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_id matches the turn.
  4. Repeat the blocked query ~15+ times in one session, then GET /api/security-warnings (operator token) → sec_error_probing fires for that actor, citing probe_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 a GUARDRAIL_*/NO_TOOLS/PLAN_UNPARSEABLE code.
  • [ ] trace_id = the turn's X-Request-Id; user_id = the turn's X-User-Id value; no message content in the event.
  • [ ] Unset AUDIT_REDIS_URL ⇒ byte-identical behavior (dormant); dead Redis ⇒ user stream unaffected.
  • [ ] Tests above green; pnpm check green; spec/plan/completion notes present; no deploy without explicit go-ahead.