Skip to content

fix(guardrails): deterministic chain order, name the blocking rule, drop dead code - #584

Merged
jarvis9443 merged 4 commits into
mainfrom
fix/guardrail-chain-519
Jun 11, 2026
Merged

fix(guardrails): deterministic chain order, name the blocking rule, drop dead code#584
jarvis9443 merged 4 commits into
mainfrom
fix/guardrail-chain-519

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Addresses B.4, B.5, E.2 of api7/AISIX-Cloud#519.

B.4a — deterministic guardrail chain order

build_chain_from_snapshot (and the implicit env-scope fallback in build_index_from_snapshot) iterated ResourceTable::entries(), which is DashMap-backed — iteration order is arbitrary and varies run to run. With multiple matching guardrails, which Block fired first was random.

Rows are now sorted (created_at asc, id asc) before the chain/index is built (rows without created_at sort after rows that have it), so evaluation order is total, deterministic, and matches the dashboard's oldest-first listing. Attachment rows are sorted by id so equal-priority/equal-specificity ties in GuardrailIndex resolve stably too.

The DP Guardrail model gains an optional created_at (RFC3339 string, #[serde(default)]). The CP will start projecting it in marshalGuardrailKV in a follow-up PR; until then every row falls back to the id tiebreak, which is still deterministic.

B.4b — name the guardrail that blocked

A blocked request previously returned 422 {"error":{"type":"content_filter","message":"request blocked by content policy"}} with no indication of WHICH guardrail fired. GuardrailChain now attributes each Block to the firing member: the verdict carries guardrail_name and the ops-log reason is prefixed with guardrail '<name>': . All proxy endpoint families (chat, completions, messages incl. streaming/passthrough, responses, embeddings, rerank, images, audio, cache-hit path) build the public message through one shared helper:

request blocked by content policy (guardrail '<name>')

error.type stays content_filter, and the #153 redaction contract is preserved — the matched-pattern detail still never reaches the wire, only the operator-assigned guardrail name does. This matches LiteLLM's behavior, which includes the guardrail identity in blocked-request error details (e.g. guardrailIdentifier for Bedrock, category/pattern_name for its content filter).

B.5 + E.2 — dead code removal

  • MaxContentLength deleted: GuardrailKind has no Length variant, so the type was unreachable from configuration.
  • GuardrailVerdict::Rewrite deleted: no guardrail implementation ever returned it. The chain's Cow<ChatFormat> propagation machinery and the proxy's rewrite-substitution path existed only for this variant and are collapsed.

No new tests for the removals — the existing suite passing covers them.

Tests

  • build.rs: chain order is created_at-ascending with id tiebreak (fails deterministically on the unfixed DashMap iteration), id-only fallback when created_at is absent, and the same ordering through the production GuardrailIndex::resolve fallback path.
  • chain.rs: Block attribution carries the firing member's name; nested chains keep the innermost attribution.
  • Proxy unit tests + DP E2E (guardrail-keyword-e2e, guardrail-output-e2e) assert the 422 / SSE-error envelope names the guardrail while still never leaking the matched literal.

Summary by CodeRabbit

  • New Features

    • Error messages now include the specific guardrail name that triggered a content block.
    • Guardrails support creation timestamps and are evaluated in a deterministic oldest-first order.
  • Removals

    • Removed the MaxContentLength guardrail (input/output character limits).
  • Documentation

    • Examples updated to show guardrail-named error messages.
  • Tests

    • End-to-end and unit tests updated to assert guardrail-named messages and deterministic ordering.

Closes api7/AISIX-Cloud#519

…rop dead code

- Sort guardrail rows (created_at asc, id tiebreak) before building the
  chain / the implicit env-scope index entries, and sort attachment rows
  by id, so which Block fires first is deterministic and matches the
  dashboard's oldest-first listing (#519 B.4a). Adds the optional
  created_at field to the DP Guardrail model; cp-api will project it in
  a follow-up, until then the id tiebreak keeps order total.
- GuardrailChain attributes Block verdicts to the firing member: the
  verdict carries guardrail_name and the ops-log reason is prefixed
  with it. Every proxy endpoint family now builds its 422 / SSE-error
  message via error::guardrail_block_message - generic policy wording
  plus the guardrail name, never the matched-pattern detail (#153
  redaction preserved) (#519 B.4b).
- Remove dead code: MaxContentLength (unreachable from config; no
  GuardrailKind::Length) and GuardrailVerdict::Rewrite (no impl ever
  returned it), collapsing the chain's Cow propagation machinery
  (#519 B.5 + E.2).
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0b6b3835-ef3a-4cf6-9825-4e37cbe35951

📥 Commits

Reviewing files that changed from the base of the PR and between ade67e9 and feecda8.

📒 Files selected for processing (3)
  • crates/aisix-core/src/models/schema.rs
  • docs/configuration/guardrails.md
  • tests/e2e/src/cases/guardrail-output-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/e2e/src/cases/guardrail-output-e2e.test.ts
  • docs/configuration/guardrails.md

📝 Walkthrough

Walkthrough

This PR refactors guardrail infrastructure to support deterministic chain member ordering via created_at timestamps and propagates guardrail names through the evaluation chain to enable per-guardrail error attribution. Core changes include adding created_at to the Guardrail model, refactoring GuardrailVerdict::Block to include optional guardrail_name, sorting chain members deterministically during snapshot build, and unifying customer-facing error messages across all proxy endpoints to include the firing guardrail identifier.

Changes

Guardrail Attribution and Deterministic Ordering

Layer / File(s) Summary
Core model and verdict enum foundation
crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-guardrails/src/lib.rs
Guardrail struct gains optional created_at: Option<String> field with serde defaults; GuardrailVerdict::Block changed to include guardrail_name: Option<String>, constructor block(reason) added, Rewrite variant removed, and MaxContentLength guardrail removed.
GuardrailVerdict constructor adoption
crates/aisix-guardrails/src/{aliyun,bedrock,keyword,prompt_shield,text_moderation}.rs
All guardrail implementations updated to use GuardrailVerdict::block(...) constructor instead of struct-variant construction; pattern matches on Block widened with .. to accommodate new guardrail_name field.
Deterministic guardrail chain and index ordering
crates/aisix-guardrails/src/build.rs
Added created_at-ascending (with etcd id tie-break) sorting helper; build_chain_from_snapshot and build_index_from_snapshot refactored to sort entries deterministically instead of relying on DashMap iteration; applied telemetry tracked only for materialized rows; new tests verify deterministic ordering and fallbacks.
ChainMember wrapper and chain evaluation refactoring
crates/aisix-guardrails/src/chain.rs
GuardrailChain refactored to wrap guardrails in ChainMember { name, guardrail }; new_with_applied now accepts Vec<(String, Arc<dyn Guardrail>)>; attribute_block helper centralizes attribution, filling guardrail_name on Block verdicts while preserving nested-chain attribution; evaluation now iterates members and returns first attributed block.
Index layer guardrail name propagation
crates/aisix-guardrails/src/index.rs
IndexEntry gains guardrail_name field; GuardrailIndex::resolve accumulates (guardrail_name, guardrail) tuples; push_entry extended with guardrail_name parameter.
Proxy error handling and guardrail_block_message helper
crates/aisix-proxy/src/{error,chat,messages,completions,embeddings,audio,images,rerank,responses}.rs
New guardrail_block_message(side, guardrail_name) helper in error.rs generates redacted customer-facing messages optionally including guardrail identifier; all proxy endpoints updated to destructure guardrail_name from Block verdicts and use helper for ContentFiltered error payloads; messages.rs adds streaming helper guardrail_block_frame(guardrail_name) for Anthropic SSE frames.
Test and documentation updates
crates/aisix-proxy/src/lib.rs, tests/e2e/src/cases/guardrail-*.test.ts, docs/{configuration,tutorials}/*
Proxy router tests updated to assert guardrail names in error messages; e2e tests verify guardrail identifiers in caller-visible payloads; tutorial and configuration documentation examples updated to show guardrail-named error responses.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • api7/ai-gateway#491 — Modifies /v1/messages output guardrail handling; related to this PR’s guardrail-name attribution and SSE framing changes.
  • api7/ai-gateway#497 — Changes Bedrock failure/block handling; related to this PR’s Bedrock guardrail verdict construction updates.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures all three main changes: deterministic chain ordering, guardrail name attribution, and removal of dead code. It is concise, specific, and clearly summarizes the primary objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/aisix-proxy/src/chat.rs (1)

2633-2656: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

BufferFull fail-closed still skips guardrail attribution.

When max_buffer_bytes is exceeded with on_exceeded_fail_open = false, this branch emits the old hardcoded "response blocked by content policy" SSE payload. That makes BufferFull the one streaming block path that bypasses guardrail_block_message() and drops the guardrail identifier the rest of this PR now surfaces. Please route this branch through the shared helper and plumb the owning guardrail name into it if the stream policy can identify one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/chat.rs` around lines 2633 - 2656, The BufferFull
branch of aisix_guardrails::StreamOutputPolicy currently emits a hardcoded SSE
error payload and skips guardrail attribution; instead call the shared helper
guardrail_block_message(...) (or the existing helper used elsewhere for blocked
responses) from the BufferFull branch when on_exceeded_fail_open is false, pass
through the same context flags (set errored and guard.comp().guardrail_blocked
as now), and plumb the owning guardrail name/identifier into that helper (derive
it from the current guard/guardrail context available in this scope) so the SSE
payload includes the proper guardrail attribution rather than the hardcoded
"response blocked by content policy". Ensure the pending drain/yield behavior
remains unchanged for the fail-open path.
🧹 Nitpick comments (2)
crates/aisix-proxy/src/completions.rs (1)

467-502: ⚡ Quick win

Assert the new guardrail-name contract here.

This test still passes if the handler regresses to the old generic "request blocked by content policy" string, because it only checks that BLOCKME is redacted. Add a positive assertion for guardrail 't' (or the exact helper output) so /v1/completions keeps the attribution behavior this PR is introducing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/completions.rs` around lines 467 - 502, The test
input_guardrail_blocks_prompt_returns_422 must also assert the new
guardrail-name contract: after decoding v from the response, add a positive
assertion that the error message includes the guardrail identifier produced by
keyword_input_guardrail (the guardrail name 't' used by that helper or the exact
helper output), e.g. assert that
v["error"]["message"].as_str().unwrap().contains("t") (or the exact string
returned by keyword_input_guardrail), while keeping the existing check that the
blocked keyword "BLOCKME" is redacted.
crates/aisix-proxy/src/error.rs (1)

206-220: ⚡ Quick win

Pin the shared block-message shape with a unit test.

guardrail_block_message() is now the single source for every 422/SSE guardrail error, but this module never asserts either the named or unnamed form. A tiny Some("foo") / None test here would catch wording drift before it fans out across all endpoint families.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/error.rs` around lines 206 - 220, Add a unit test that
pins the exact output of guardrail_block_message for both Some and None cases to
prevent wording drift: write a test function (e.g.,
test_guardrail_block_message_named_and_unnamed) that calls
guardrail_block_message("request" or "response", Some("foo")) and
guardrail_block_message(..., None) and asserts the returned Strings equal the
expected literals ("request blocked by content policy (guardrail 'foo')" and
"request blocked by content policy" or the analogous "response" variants); place
this test in the same module (inside #[cfg(test)] mod tests) so it runs with
cargo test and fails on any accidental wording change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-core/src/models/schema.rs`:
- Line 418: The schema currently allows any string for the "created_at" field,
which breaks lexicographic timestamp ordering; change the JSON schema entry for
"created_at" in schema.rs to enforce canonical RFC3339 UTC (UTC Zulu,
zero-padded) by adding a "format": "date-time" and/or a strict regex pattern
(e.g. requiring trailing "Z" and ISO8601 fields) so values are lexicographically
sortable, and ensure any related deserialization/validation code (e.g., code
paths that parse or validate "created_at") uses the same RFC3339 UTC check so
invalid values are rejected at parse/validation time.

In `@docs/configuration/guardrails.md`:
- Around line 262-263: Update the explanatory text near the example to reflect
that the error now includes the guardrail name (e.g., guardrail 'block-secrets')
inside error.message rather than a generic content_filter message; change any
phrases that say the rule name is omitted to state that error.message includes
guardrail '<name>' and show how operators should parse/read error.error.type and
error.message to get the guardrail attribution (referencing error.message and
the guardrail 'block-secrets' example).

In `@tests/e2e/src/cases/guardrail-output-e2e.test.ts`:
- Around line 203-205: Replace the broad check against errorBlob with a direct
assertion on the thrown error's message: locate the test where errorBlob is
asserted (the caught error is available as caught / error) and change the
expectation to assert that caught.error.message (or error.message) contains
"guardrail 'gr-out-e2e-keyword'"; this ensures the guardrail attribution lives
on the error.message itself rather than some other field.

---

Outside diff comments:
In `@crates/aisix-proxy/src/chat.rs`:
- Around line 2633-2656: The BufferFull branch of
aisix_guardrails::StreamOutputPolicy currently emits a hardcoded SSE error
payload and skips guardrail attribution; instead call the shared helper
guardrail_block_message(...) (or the existing helper used elsewhere for blocked
responses) from the BufferFull branch when on_exceeded_fail_open is false, pass
through the same context flags (set errored and guard.comp().guardrail_blocked
as now), and plumb the owning guardrail name/identifier into that helper (derive
it from the current guard/guardrail context available in this scope) so the SSE
payload includes the proper guardrail attribution rather than the hardcoded
"response blocked by content policy". Ensure the pending drain/yield behavior
remains unchanged for the fail-open path.

---

Nitpick comments:
In `@crates/aisix-proxy/src/completions.rs`:
- Around line 467-502: The test input_guardrail_blocks_prompt_returns_422 must
also assert the new guardrail-name contract: after decoding v from the response,
add a positive assertion that the error message includes the guardrail
identifier produced by keyword_input_guardrail (the guardrail name 't' used by
that helper or the exact helper output), e.g. assert that
v["error"]["message"].as_str().unwrap().contains("t") (or the exact string
returned by keyword_input_guardrail), while keeping the existing check that the
blocked keyword "BLOCKME" is redacted.

In `@crates/aisix-proxy/src/error.rs`:
- Around line 206-220: Add a unit test that pins the exact output of
guardrail_block_message for both Some and None cases to prevent wording drift:
write a test function (e.g., test_guardrail_block_message_named_and_unnamed)
that calls guardrail_block_message("request" or "response", Some("foo")) and
guardrail_block_message(..., None) and asserts the returned Strings equal the
expected literals ("request blocked by content policy (guardrail 'foo')" and
"request blocked by content policy" or the analogous "response" variants); place
this test in the same module (inside #[cfg(test)] mod tests) so it runs with
cargo test and fails on any accidental wording change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 897a5181-78c6-4458-b792-5fd9cd3d6368

📥 Commits

Reviewing files that changed from the base of the PR and between 103d3ec and 5cc77fa.

📒 Files selected for processing (28)
  • crates/aisix-core/src/models/guardrail.rs
  • crates/aisix-core/src/models/schema.rs
  • crates/aisix-guardrails/src/aliyun.rs
  • crates/aisix-guardrails/src/bedrock.rs
  • crates/aisix-guardrails/src/build.rs
  • crates/aisix-guardrails/src/chain.rs
  • crates/aisix-guardrails/src/index.rs
  • crates/aisix-guardrails/src/keyword.rs
  • crates/aisix-guardrails/src/length.rs
  • crates/aisix-guardrails/src/lib.rs
  • crates/aisix-guardrails/src/prompt_shield.rs
  • crates/aisix-guardrails/src/text_moderation.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/error.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • docs/configuration/guardrails.md
  • docs/tutorials/add-keyword-guardrails.md
  • docs/tutorials/block-content-with-aws-bedrock-guardrails.md
  • docs/tutorials/block-prompt-injection-with-azure-content-safety.md
  • tests/e2e/src/cases/guardrail-keyword-e2e.test.ts
  • tests/e2e/src/cases/guardrail-output-e2e.test.ts
💤 Files with no reviewable changes (1)
  • crates/aisix-guardrails/src/length.rs

Comment thread crates/aisix-core/src/models/schema.rs Outdated
Comment thread docs/configuration/guardrails.md
Comment thread tests/e2e/src/cases/guardrail-output-e2e.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
schemas/resources/guardrail.schema.json (1)

322-328: 💤 Low value

Consider adding "format": "date-time" validation.

The schema accepts any string value for created_at, even though the description specifies RFC3339. Adding "format": "date-time" provides schema-level validation as a defense-in-depth measure, catching malformed timestamps before they reach the Rust deserializer.

📋 Suggested format constraint
     "created_at": {
       "description": "RFC3339 creation timestamp of the row, projected by cp-api so the DP can evaluate guardrail chains oldest-first — matching the order the dashboard lists them in (`#519` B.4a). Optional: cp-api's `marshalGuardrailKV` doesn't emit it yet, and admin-API rows may omit it; rows without it sort after rows that have it, tied broken by id, so chain order stays total and deterministic either way. RFC3339 timestamps in a fixed (UTC) offset compare correctly as strings, so no parsing is needed.",
+      "format": "date-time",
       "type": [
         "string",
         "null"
       ]
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@schemas/resources/guardrail.schema.json` around lines 322 - 328, The
created_at property currently allows any string or null but lacks RFC3339
validation; update the guardrail.schema.json entry for "created_at" to include a
"format": "date-time" alongside the existing "type": ["string","null"] so the
schema enforces date-time formatting for non-null values (keep the nullable
behavior intact), and run/update any schema tests that validate timestamp
formatting to reflect this constraint.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@schemas/resources/guardrail.schema.json`:
- Around line 322-328: The created_at property currently allows any string or
null but lacks RFC3339 validation; update the guardrail.schema.json entry for
"created_at" to include a "format": "date-time" alongside the existing "type":
["string","null"] so the schema enforces date-time formatting for non-null
values (keep the nullable behavior intact), and run/update any schema tests that
validate timestamp formatting to reflect this constraint.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c84de3ee-1971-47fb-ac7d-7a49e58f7358

📥 Commits

Reviewing files that changed from the base of the PR and between 5cc77fa and ade67e9.

📒 Files selected for processing (1)
  • schemas/resources/guardrail.schema.json

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant