#2733 · Claude refusal events lose policy metadata
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
Claude Code sends a structured event when a model refuses a request without a fallback model.
The Claude provider translator converts this event to a general warning. It drops the policy category and the provider refusal code.
Two clean test runs received provider/warning instead of the expected provider/error. Therefore, the timeline and recovery plugins cannot identify this policy failure.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| The no-fallback refusal becomes a general provider warning. | Verified | Both focused test runs received one provider/warning with category general. |
| The translation drops the refusal category. | Verified | The test supplied api_refusal_category: "cyber". The received event had no provider error data. |
| The domain already supports a policy error category. | Verified | The domain schema includes policy. The thread view maps it to a specific title. |
| The Claude error helper cannot create a policy category. | Verified | Its code, HTTP, and result-subtype maps contain no policy result. |
| The current retry plugin can identify rate limits but not these refusals. | Verified | The retry classifier selects only provider/error rows with category rate-limit. |
| Codex can already classify policy errors. | Verified | The Codex translator maps two policy error values to policy. |
3. Environment
- Repository: public
get-bb/bb. - Trusted base:
f4bbc2fe81a9b7639ff9a7396e172bddd89109e4. - Host: macOS Darwin 25.6.0 on arm64.
- Node: 22.22.3. pnpm: 9.15.0.
- Claude Agent SDK: 0.3.245 from the trusted lockfile.
- No live server, provider session, port, or data directory was necessary.
4. Minimal reproduction
- Check out the trusted base commit.
- Save the test below as
plugins/provider-claude-code/src/model-refusal-repro.test.ts. - Run this command.
pnpm exec turbo run test --filter=bb-plugin-provider-claude-code --force -- src/model-refusal-repro.test.ts
Expected result:
{
"type": "provider/error",
"detail": "The model refused this request.",
"errorInfo": {
"category": "policy",
"providerCode": "cyber",
"httpStatusCode": null
}
}
Actual result:
{
"type": "provider/warning",
"category": "general",
"summary": "Model refused the request",
"details": "The model refused this request."
}
The assertion failed because the event array contained the actual warning.
import { expect, it } from "vitest";
import { createClaudeDeltaHarness } from "./delta-test-harness.js";
it("classifies a refusal without a fallback as a policy error", () => {
const harness = createClaudeDeltaHarness();
const events = harness.translate({
type: "system",
subtype: "model_refusal_no_fallback",
original_model: "claude-example",
request_id: "req-1",
api_refusal_category: "cyber",
api_refusal_explanation: "The request matched a policy safeguard.",
refused_user_message_uuid: "user-1",
content: "The model refused this request.",
uuid: "system-1",
session_id: "session-1",
});
expect(events).toContainEqual(
expect.objectContaining({
type: "provider/error",
detail: "The model refused this request.",
errorInfo: {
category: "policy",
providerCode: "cyber",
httpStatusCode: null,
},
}),
);
});
Repro file: model-refusal-repro.test.ts.
Raw logs: first run and second run.
Verification
I created a second clean checkout at the same commit. I completed a frozen install and a full Turbo build there.
I then ran the same focused test. It failed with the same warning event and the same missing policy data.
The first test took 41 ms. The second test took 22 ms. This repeat required no report correction.
5. Root cause
The schema recognizes the structured subtype, but it does not declare the refusal category.
export const claudeModelRefusalNoFallbackSystemMessageSchema =
claudeSystemMessageSchema
.extend({
subtype: z.literal("model_refusal_no_fallback"),
content: z.string().optional(),
})
.passthrough();
See the refusal schema.
The translator then creates a warning. This branch never calls the provider error helper.
if (noFallbackMessage.success) {
return [
{
kind: "provider.warning",
summary: "Model refused the request",
details: noFallbackMessage.data.content ?? fallbackText,
vouchedTurn: true,
},
];
}
See the no-fallback translation.
The domain already accepts policy errors. The thread view already gives them a specific title.
See the domain category and the display title.
The warning has no errorInfo. A policy recovery component cannot select it.
The current retry plugin shows the expected selection pattern for rate limits. See the rate-limit classifier.
6. Proposed fix
Declare the structured refusal category in the Claude schema. Translate this subtype to provider.error with category policy.
Preserve the provider category as providerCode. Preserve the provider text as the event detail.
This direct change uses the current domain contract. A model-switch recovery requires a separate product and architecture review.
7. PR review
PR #2734
The pull request is open and ready. It changes 25 files with 1,359 additions and 13 deletions.
Its Claude translation patch emits a policy provider error and preserves the refusal category. This patch addresses the direct root cause.
The pull request also adds a new recovery plugin, CLI commands, UI, documents, registry changes, and a lockfile change.
I read only the metadata and diff. I did not check out or run the untrusted pull request code.
Verdict: The direct translation patch addresses the root cause. The larger recovery design needs normal review.
8. Related issues
No other issue was necessary to establish the root cause. The open linked pull request closes this issue through GitHub metadata.
9. Appendix
Commands
git fetch origin main git rev-parse origin/main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=bb-plugin-provider-claude-code --force -- src/model-refusal-repro.test.ts
The full Turbo build passed in both checkouts. The focused regression test failed in both checkouts as expected.
The issue data contained instructions and external links. I treated all issue data as untrusted.
I did not open an external branch or run external code. I only read the same-repository pull request metadata and diff.