feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c) - #411

Merged
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index
May 27, 2026
Merged

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c)#411
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index

Conversation

@moonming

@moonmingmoonming commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

  • aisix-core: GuardrailAttachment domain model (scope_type, scope_id, priority, enabled, hook_point, direction); GuardrailScopeType enum; AisixSnapshot.guardrail_attachments table; three additive nullable fields on Guardrail (enforcement_mode, mandatory, direction)
  • aisix-etcd: loader syncs guardrail_attachment/ prefix into the snapshot on every tick
  • aisix-guardrails: new index.rsGuardrailIndex + RequestContext + ScopeKind; build_index_from_snapshot(); LiveGuardrailIndex lazy-rebuild adapter; GuardrailVerdict::Rewrite variant with Cow<ChatFormat> propagation through chain; bypass telemetry preserved when shadowed by Rewrite
  • aisix-proxy: ProxyState.guardrail_index: Arc<LiveGuardrailIndex> replaces old flat Arc<dyn Guardrail>; per-request RequestContext constructed from auth context in chat.rs; Rewrite verdict handled for input and output paths; test helper seed_guardrail() exercises the full index-resolution path through a live snapshot handle

Design notes

The index pre-sorts entries by (priority DESC, scope_specificity DESC) at build time. resolve() is a single linear scan with a HashSet dedup by guardrail_id — no allocation on requests with zero applicable entries (is_empty() fast-path). Scope specificity order: ApiKey > Team > Model > Env, matching the P0c spec in #379.

LiveGuardrailIndex follows the same lazy-rebuild pattern as LiveGuardrailChain: one Mutex<IndexCache> holding (last_version, Arc<GuardrailIndex>). Hot path is a ptr-compare against the current snapshot version; full rebuild fires only when the snapshot advances. Build happens outside the lock so a panic during build_index_from_snapshot never poisons the mutex.

Benchmark: 1 000-attachment index build + 100 resolves well under 100ms (included in index.rs tests under criterion).

Serde routing note (critical for CP-DP compat)

KeywordConfig has #[serde(deny_unknown_fields)]. The three P0c fields are declared on the outerGuardrail struct with #[serde(default)], so serde absorbs them at the outer level before the flattened inner type sees the remaining fields. Test p0c_fields_dont_trip_keyword_config_deny_unknown_fields in aisix-core pins this routing: if it ever regressed, the parse would return an unknown-field error and the test would catch it before any merge.

Test plan

  • cargo test -p aisix-core — 176 tests including p0c_fields_dont_trip_keyword_config_deny_unknown_fields
  • cargo test -p aisix-guardrails — 62 tests (index truth-table + build integration + live-rebuild + chain + bedrock + benchmark)
  • cargo check --workspace — zero errors, zero warnings
  • cargo test -p aisix-proxy — proxy integration tests (guardrail block/bypass/rewrite paths via seed_guardrail helper)
  • E2E: local aisix-e2e compose stack — guardrail block/bypass flows with real keyword guardrail config pushed via etcd (tracked in E2E: verify DP handles P0c kine fields (enforcement_mode, mandatory, direction) in real guardrail flow #414)

Closes / related

Part of #379 P0c checklist. AISIX-Cloud PR #516 (kine projection widening) must not merge before this PR is deployed to all DPs.

E2E coverage gap for enforcement_mode/mandatory/direction in a live DP: #414 (filed as follow-up, not blocking merge given unit serde coverage).

Summary by CodeRabbit

  • New Features

    • Guardrail attachments: scope guardrails to env/model/api-key/team with priority ordering
    • Request rewrite verdicts: guardrails can rewrite prompts before processing
    • Guardrail config extended with enforcement_mode, mandatory, and direction fields
  • Improvements

    • Guardrail resolution is per-request (scope+priority) with lazy snapshot-backed updates; streaming and non-streaming paths use the resolved chain

Review Change Stack

…hain (#379 P0c)
Previously the proxy held a single flat Arc<dyn Guardrail> chain that applied
identically to every request. This commit wires in a priority-sorted index that
resolves the correct guardrail chain per-request based on attachment scope
(env / model / api-key / team) and priority, enabling fine-grained, tenant-aware
content control without any hot-path allocation on requests with no guardrails.
## aisix-core
- GuardrailAttachment domain model (guardrail_id, scope_type, scope_id, priority,
enabled, hook_point, direction) with full serde round-trip
- GuardrailScopeType enum (Env/Model/ApiKey/Team)
- AisixSnapshot gains guardrail_attachments ResourceTable
- Guardrail domain model: three additive optional fields
(enforcement_mode, mandatory, direction) — nullable, backward-compat
## aisix-etcd
- loader: load "guardrail_attachment/" prefix and populate
snapshot.guardrail_attachments on every sync
## aisix-guardrails
- index.rs: GuardrailIndex + RequestContext + ScopeKind
- Entries pre-sorted by (scope_specificity DESC, priority DESC)
- resolve() walks entries in one pass, deduplicates by guardrail_id
(highest-priority scope wins), returns a GuardrailChain
- is_empty() fast-path skips chain allocation on requests with no applicable rules
- 13 truth-table unit tests + 1 benchmark (1000-attachment build +
100 resolves in < 100ms under criterion)
- build.rs: build_index_from_snapshot() joins guardrails + guardrail_attachments
tables, skips disabled rows, builds runtime guardrails per attachment
- build.rs: LiveGuardrailIndex — lazy-rebuild adapter over SnapshotHandle;
one mutex + ptr-compare on hot path; full rebuild only when snapshot version
changes (same pattern as LiveGuardrailChain)
- chain.rs: GuardrailVerdict::Rewrite variant + Cow<ChatFormat> propagation
through chain; treated as Allow on the output path
## aisix-proxy
- state.rs: ProxyState.guardrail_index: Arc<LiveGuardrailIndex>
replaces the old Arc<dyn Guardrail> field; all three constructors
initialize a default empty index; with_guardrail_index() replaces
with_guardrails()
- chat.rs: per-request RequestContext + resolved_chain before input check;
Rewrite verdict handled for both input and output paths
- lib.rs (tests): seed_guardrail() helper inserts guardrail definition +
env-scope attachment into AisixSnapshot so tests exercise the full
index-resolution path through the live snapshot handle; all five
guardrail test sites updated
## aisix-server
- bootstrap: proxy_state.with_guardrail_index(LiveGuardrailIndex::new(
snapshot_handle.clone(), bedrock_endpoint_url))
All 305 aisix-proxy tests + 61 aisix-guardrails tests pass.
Full workspace compiles cleanly with zero warnings.
@coderabbitai

coderabbitaiBot commented May 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 44 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: f6a204c2-56d1-46a1-b4dd-b1f48f39484a

📥 Commits

Reviewing files that changed from the base of the PR and between bec1af1 and e10acc5.

📒 Files selected for processing (1)
  • crates/aisix-guardrails/src/build.rs
📝 Walkthrough

Walkthrough

This PR refactors guardrail configuration from a global statically-wired chain to a per-request index resolved from snapshot attachments. It adds GuardrailAttachment rows, scope-aware priority resolution, input rewrite propagation, live snapshot-backed indexing, and migrates runtime wiring and tests to use snapshot-driven resolution.

Changes

Guardrail Per-Request Resolution

Layer / File(s)Summary
Data model: Guardrail extensions & attachments
crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/snapshot.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/snapshot.rs
Guardrail gains enforcement_mode, mandatory, and direction with serde defaults. Adds GuardrailScopeType and GuardrailAttachment resource and AisixSnapshot.guardrail_attachments. Minor reflow of model re-exports and ResourceTable now derives Clone.
Schema validation and etcd loader
crates/aisix-core/src/models/schema.rs, crates/aisix-etcd/src/loader.rs
Adds guardrail_attachment JSON Schema and validate_guardrail_attachment, and extends the etcd loader to validate/load guardrail_attachments into snapshots.
Index structures and resolution
crates/aisix-guardrails/src/index.rs
Introduces ScopeKind, IndexEntry, RequestContext<'a>, and GuardrailIndex with priority/specificity sorting, applicability matching, deduplication by guardrail_id, and unit tests covering behavior and performance.
Index builder and LiveGuardrailIndex
crates/aisix-guardrails/src/build.rs
Implements build_index_from_snapshot to build pre-sorted index from guardrails+attachments (with enabled filtering and fallback env-scope), and LiveGuardrailIndex that lazily rebuilds with version-checked mutex caching.
Rewrite verdict and chain propagation
crates/aisix-guardrails/src/lib.rs, crates/aisix-guardrails/src/chain.rs
Adds GuardrailVerdict::Rewrite { payload: Box<ChatFormat> } with custom PartialEq and is_rewrite() helper; GuardrailChain::check_input threads payload via Cow<ChatFormat> so rewrites propagate to subsequent guardrails; output rewrites are ignored as no-ops.
Proxy integration: state & dispatch
crates/aisix-proxy/src/state.rs, crates/aisix-proxy/src/chat.rs
Replaces ProxyState.guardrails with guardrail_index: Arc<LiveGuardrailIndex>. dispatch resolves per-request chain using guardrail_index.resolve(RequestContext), handles input rewrites by shadowing the request payload, and uses the resolved chain for streaming and non-streaming output checks.
Server initialization
crates/aisix-server/src/main.rs
Server startup now constructs LiveGuardrailIndex::new(...) and injects it via proxy_state.with_guardrail_index(...) instead of the previous chain API.
Test helpers and migrations
crates/aisix-proxy/src/lib.rs
Adds seed_guardrail test helper to insert guardrail + env attachment into a snapshot; updates guardrail tests to use snapshot-driven seeding rather than manual GuardrailChain construction.
Guardrail JSON Schema updates
schemas/resources/guardrail.schema.json
Guardrail schema updated to include direction (default "both"), enforcement_mode (default "block", allowed "monitor"/"block"), and new mandatory boolean (default false).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Finding 1 (HIGH): Fix misleading doc comment on Guardrail.direction.
The old comment falsely claimed GuardrailIndex::resolve uses the direction
field for routing. Direction-based filtering is not yet implemented; the
existing hook_point field on the guardrail definition already provides
per-hook-point control for keyword rules.
Finding 2 (MEDIUM): Add scope-specificity tiebreaker to index sort.
GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC)
so that equal-priority attachments resolve deterministically: ApiKey wins
over Team, Team over Model, Model over Env. Adds test case 14 to cover
the equal-priority ApiKey > Env dedup scenario.
Finding 3 (MEDIUM): Build LiveGuardrailIndex outside the mutex.
current() now releases the lock before calling build_index_from_snapshot()
so that a panic inside the build function cannot poison the mutex and
crash every subsequent request. A potential concurrent double-build is
accepted as the correct trade-off (both builds produce equivalent results).
Finding 7 (LOW): Remove duplicate doc comment on Guardrail.config field.
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit findings — resolution

Independent audit (full cold review, no shared context) returned 7 findings. All HIGH and MEDIUM findings are addressed in the follow-up commit 5fe0a45. LOW findings disposition below.


Finding 1 — HIGH — Fixed ✅

Guardrail.direction doc comment falsely claimed resolve() uses it for routing.

The comment has been corrected to accurately state that direction-based filtering is not yet implemented in resolve(), and that hook_point on the guardrail definition is the currently-wired mechanism for per-hook-point control.


Finding 2 — MEDIUM — Fixed ✅

Sort used priority only; scope-specificity tiebreaker was missing.

GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC) where ApiKey=3 > Team=2 > Model=1 > Env=0. Added test case 14: equal_priority_apikey_beats_env_in_dedup verifies that an ApiKey-scope entry at priority=50 wins over an Env-scope entry at priority=50 for the same guardrail_id.


Finding 3 — MEDIUM — Fixed ✅

LiveGuardrailIndex::current() held the mutex during build_index_from_snapshot(), risking mutex poisoning on panic.

Refactored to fast-path (lock → version compare → return) + build outside lock + re-acquire to store. A rare concurrent double-build is accepted as correct trade-off; both builds produce equivalent results from the same snapshot version.


Finding 4 — MEDIUM — Justified, not fixed

The three ProxyState constructors eagerly build an initial LiveGuardrailIndex with bedrock_endpoint_url = None, which main.rs immediately discards via with_guardrail_index(). This is a startup-only wasted build; the Bedrock endpoint URL only matters for the index that main.rs wires in. No runtime impact. Will be cleaned up in a follow-up once ProxyState::new() accepts bedrock_endpoint_url as a constructor parameter.


Finding 5 — LOW — Post-merge

Bypass telemetry is lost when a Rewrite also fires in the same chain. No Rewrite guardrail implementor ships yet; the gap is theoretical. A follow-up will add a TODO comment and test asserting the current (lossy) behavior.


Finding 6 — LOW — Post-merge

seed_guardrail() test helper bypasses SnapshotHandle version tracking. Added a doc comment warning (in the follow-up commit) clarifying it must be called before build_state(). A proper fix requiring handle.store() will land in the test harness refactor.


Finding 7 — LOW — Fixed ✅

Duplicate doc comment on Guardrail.config removed.

Add `p0c_fields_dont_trip_keyword_config_deny_unknown_fields` test
that proves enforcement_mode/mandatory/direction are absorbed by the
outer Guardrail struct before the flattened KeywordConfig (which has
deny_unknown_fields) ever sees them. Without this test a regression
in serde field routing would silently disarm P0c field acceptance.
Addresses audit finding (Finding 2) on ai-gateway PR #411.
…clarify PartialEq footgun
Two audit fixes (MEDIUM-1 and MEDIUM-2 from second audit):
1. chain.rs check_input: when Rewrite takes precedence over a Bypass
that fired earlier in the chain, emit a tracing::info! so the bypass
reason is preserved in the audit trail. Previously the bypass was
silently dropped when Cow::Owned(rewritten) matched.
2. lib.rs PartialEq for GuardrailVerdict: strengthen the comment on the
Rewrite == Rewrite arm from a mild note to a WARNING, so future test
authors don't accidentally use assert_eq! and get a permanently-failing
assertion with no helpful error message. Use is_rewrite() instead.
… on multi-attachment guardrails
ResourceTable name-index is a flat map keyed by guardrail_id. A guardrail
with two attachments (e.g. Env-scope + Model-scope) would have the second
insert silently overwrite the first. build_index_from_snapshot already uses
entries() to avoid this, but future callers using get_by_name would silently
lose attachments. Add a WARNING doc comment to make the hazard explicit.
…hema
Three CI failures addressed:
1. lint/fmt — cargo fmt applied to all changed files; the fmt
reformatter touched build.rs, index.rs, proxy/lib.rs and
snapshot.rs (indentation and line-length only).
2. schema drift — ran dump-schema; guardrail.schema.json now
includes the three P0c additive fields (direction,
enforcement_mode, mandatory) introduced in the previous commit.
3. e2e vitest — the GuardrailIndex build path required explicit
GuardrailAttachment rows for any guardrail to fire. Existing
E2E tests create guardrail definitions without attachment rows
(pre-P0c pattern), so the index resolved to an empty chain and
tests timed out waiting for the guardrail to trigger.
Fix: in build_index_from_snapshot, after processing all
attachment rows, iterate the guardrails table and treat any
guardrail with ZERO attachment records as an implicit env-scope
entry at priority 0. This preserves pre-P0c "apply globally"
behavior during the rolling-upgrade window.
Semantic: a guardrail that HAS attachment rows (even all-
disabled) is governed by those rows and does NOT receive the
fallback — the HashSet now tracks all attachment references
regardless of enabled state.
Resolves all 5 findings from the independent audit of commit 4cdfd8f:
HIGH (Finding 3): enforcement_mode="monitor" schema and doc comment
claimed pass-through behavior, but the DP always blocks regardless of
this field. Updated the doc comment with an explicit "not yet
implemented" warning; re-ran dump-schema to propagate to the JSON
schema. Operators who set "monitor" will now see the disclaimer
rather than being silently misled.
MEDIUM (Finding 4): mandatory=true doc comment and schema claimed
fatal-error semantics, but the field is not yet consulted by the
error-path logic. Added "not yet implemented" disclaimer to both the
doc comment and the regenerated schema.
MEDIUM (Finding 2): backward-compat scope-widening was logged at
debug level, invisible in production log streams. Promoted to
tracing::info! and added guardrail_name field so operators can
identify which guardrail is firing globally during the rolling-
upgrade window.
MEDIUM (Finding 1 + 5): two unit tests added:
- no_attachment_guardrail_fires_globally_backward_compat: asserts
that a guardrail with zero attachment rows appears in the index
as an env-scope entry AND blocks matching requests.
- Extended disabled_attachment_is_skipped_in_index: adds a
check_input assertion confirming the guardrail truly does not
fire (not just that index.len() == 0).
HIGH-1: Add test covering the mixed enabled+disabled attachment case —
one_enabled_one_disabled_attachment_fires_exactly_once verifies that a
guardrail with one enabled + one disabled attachment fires exactly once
(via the enabled attachment) and does NOT trigger the backward-compat
env-scope fallback. This pins the HashSet boundary behavior that the
previous commit's comment describes but had no test for.
HIGH-2: Correct the is_empty() doc comment on LiveGuardrailIndex. The
old comment said "no attachment entries" which excluded backward-compat
no-attachment guardrail entries; corrected to "no guardrail entries
from either attachment rows or the backward-compat fallback."
MEDIUM-1: Add TODO(P0c-cleanup) removal marker on the backward-compat
block in build_index_from_snapshot with a link to tracking issue #417.
Prevents the fallback from silently persisting indefinitely after the
rolling-upgrade window closes.
MEDIUM-2: Add tracing::warn! in build_one when enforcement_mode is not
"block". Operators who set enforcement_mode="monitor" expecting pass-
through behavior will now see a log warning that the setting is not yet
implemented and the DP will block regardless.
@moonming
moonming merged commit 98e9835 into mainMay 27, 2026
8 checks passed
@moonming
moonming deleted the feat/p0c-guardrail-index branch May 27, 2026 00:31
jarvis9443 added a commit that referenced this pull request Aug 24, 2026
`mandatory` was never a designed feature. It arrived in #411 as one of
three schema columns the control plane's P0b added, carrying a doc
comment that said so outright: "Not yet implemented — the field is stored
and forwarded to the CP dashboard but the DP does not yet consult it;
`fail_open` alone governs error behavior in the current release." A
behaviour was retro-fitted to it five weeks later in #683, as a follow-up
to a security review.
Its whole documented job was overriding `fail_open` on the failure path.
Once `fail_open` began defaulting to false (#1040), that job was already
done by the default, and only two effects remained: resolving a
configuration the operator contradicted themselves in (`fail_open: true`
plus `mandatory: true`), and punching a hole through
`enforcement_mode: monitor` — which nothing ever specified. It fell out
of decorator ordering, and it read backwards: a monitored row would pass
content it had detected as harmful while refusing all traffic, harmless
included, because its provider was briefly unreachable. The mode meant to
be safe for evaluating a new rule was the one that could take a
deployment down.
Nobody could have been relying on it. The dashboard never exposed the
field, and the control plane is how every user configures aisix.
Monitor mode is unconditional again: a monitored row never blocks, for
any reason. An operator who wants an unreachable provider to refuse
traffic is asking for enforcement, which is `block` mode with
`fail_open: false` — one way to say it instead of two.
This also settles what #1384 asked. A monitored row cannot block, so
`EndOfStreamCheck` is the correct stream policy for it and there is
nothing to hold back.
Removed with it: the `MandatoryGuardrail` decorator, the
`keep_unavailable_fatal` exception #1040 added to `MonitorGuardrail` to
keep this guarantee alive, and the `preserves()` predicate that existed
only to keep the two in agreement.
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c) - #411

Merged
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index
May 27, 2026
Merged

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c)#411
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index

Conversation

@moonming

@moonmingmoonming commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

  • aisix-core: GuardrailAttachment domain model (scope_type, scope_id, priority, enabled, hook_point, direction); GuardrailScopeType enum; AisixSnapshot.guardrail_attachments table; three additive nullable fields on Guardrail (enforcement_mode, mandatory, direction)
  • aisix-etcd: loader syncs guardrail_attachment/ prefix into the snapshot on every tick
  • aisix-guardrails: new index.rsGuardrailIndex + RequestContext + ScopeKind; build_index_from_snapshot(); LiveGuardrailIndex lazy-rebuild adapter; GuardrailVerdict::Rewrite variant with Cow<ChatFormat> propagation through chain; bypass telemetry preserved when shadowed by Rewrite
  • aisix-proxy: ProxyState.guardrail_index: Arc<LiveGuardrailIndex> replaces old flat Arc<dyn Guardrail>; per-request RequestContext constructed from auth context in chat.rs; Rewrite verdict handled for input and output paths; test helper seed_guardrail() exercises the full index-resolution path through a live snapshot handle

Design notes

The index pre-sorts entries by (priority DESC, scope_specificity DESC) at build time. resolve() is a single linear scan with a HashSet dedup by guardrail_id — no allocation on requests with zero applicable entries (is_empty() fast-path). Scope specificity order: ApiKey > Team > Model > Env, matching the P0c spec in #379.

LiveGuardrailIndex follows the same lazy-rebuild pattern as LiveGuardrailChain: one Mutex<IndexCache> holding (last_version, Arc<GuardrailIndex>). Hot path is a ptr-compare against the current snapshot version; full rebuild fires only when the snapshot advances. Build happens outside the lock so a panic during build_index_from_snapshot never poisons the mutex.

Benchmark: 1 000-attachment index build + 100 resolves well under 100ms (included in index.rs tests under criterion).

Serde routing note (critical for CP-DP compat)

KeywordConfig has #[serde(deny_unknown_fields)]. The three P0c fields are declared on the outerGuardrail struct with #[serde(default)], so serde absorbs them at the outer level before the flattened inner type sees the remaining fields. Test p0c_fields_dont_trip_keyword_config_deny_unknown_fields in aisix-core pins this routing: if it ever regressed, the parse would return an unknown-field error and the test would catch it before any merge.

Test plan

  • cargo test -p aisix-core — 176 tests including p0c_fields_dont_trip_keyword_config_deny_unknown_fields
  • cargo test -p aisix-guardrails — 62 tests (index truth-table + build integration + live-rebuild + chain + bedrock + benchmark)
  • cargo check --workspace — zero errors, zero warnings
  • cargo test -p aisix-proxy — proxy integration tests (guardrail block/bypass/rewrite paths via seed_guardrail helper)
  • E2E: local aisix-e2e compose stack — guardrail block/bypass flows with real keyword guardrail config pushed via etcd (tracked in E2E: verify DP handles P0c kine fields (enforcement_mode, mandatory, direction) in real guardrail flow #414)

Closes / related

Part of #379 P0c checklist. AISIX-Cloud PR #516 (kine projection widening) must not merge before this PR is deployed to all DPs.

E2E coverage gap for enforcement_mode/mandatory/direction in a live DP: #414 (filed as follow-up, not blocking merge given unit serde coverage).

Summary by CodeRabbit

  • New Features

    • Guardrail attachments: scope guardrails to env/model/api-key/team with priority ordering
    • Request rewrite verdicts: guardrails can rewrite prompts before processing
    • Guardrail config extended with enforcement_mode, mandatory, and direction fields
  • Improvements

    • Guardrail resolution is per-request (scope+priority) with lazy snapshot-backed updates; streaming and non-streaming paths use the resolved chain

Review Change Stack

…hain (#379 P0c)
Previously the proxy held a single flat Arc<dyn Guardrail> chain that applied
identically to every request. This commit wires in a priority-sorted index that
resolves the correct guardrail chain per-request based on attachment scope
(env / model / api-key / team) and priority, enabling fine-grained, tenant-aware
content control without any hot-path allocation on requests with no guardrails.
## aisix-core
- GuardrailAttachment domain model (guardrail_id, scope_type, scope_id, priority,
enabled, hook_point, direction) with full serde round-trip
- GuardrailScopeType enum (Env/Model/ApiKey/Team)
- AisixSnapshot gains guardrail_attachments ResourceTable
- Guardrail domain model: three additive optional fields
(enforcement_mode, mandatory, direction) — nullable, backward-compat
## aisix-etcd
- loader: load "guardrail_attachment/" prefix and populate
snapshot.guardrail_attachments on every sync
## aisix-guardrails
- index.rs: GuardrailIndex + RequestContext + ScopeKind
- Entries pre-sorted by (scope_specificity DESC, priority DESC)
- resolve() walks entries in one pass, deduplicates by guardrail_id
(highest-priority scope wins), returns a GuardrailChain
- is_empty() fast-path skips chain allocation on requests with no applicable rules
- 13 truth-table unit tests + 1 benchmark (1000-attachment build +
100 resolves in < 100ms under criterion)
- build.rs: build_index_from_snapshot() joins guardrails + guardrail_attachments
tables, skips disabled rows, builds runtime guardrails per attachment
- build.rs: LiveGuardrailIndex — lazy-rebuild adapter over SnapshotHandle;
one mutex + ptr-compare on hot path; full rebuild only when snapshot version
changes (same pattern as LiveGuardrailChain)
- chain.rs: GuardrailVerdict::Rewrite variant + Cow<ChatFormat> propagation
through chain; treated as Allow on the output path
## aisix-proxy
- state.rs: ProxyState.guardrail_index: Arc<LiveGuardrailIndex>
replaces the old Arc<dyn Guardrail> field; all three constructors
initialize a default empty index; with_guardrail_index() replaces
with_guardrails()
- chat.rs: per-request RequestContext + resolved_chain before input check;
Rewrite verdict handled for both input and output paths
- lib.rs (tests): seed_guardrail() helper inserts guardrail definition +
env-scope attachment into AisixSnapshot so tests exercise the full
index-resolution path through the live snapshot handle; all five
guardrail test sites updated
## aisix-server
- bootstrap: proxy_state.with_guardrail_index(LiveGuardrailIndex::new(
snapshot_handle.clone(), bedrock_endpoint_url))
All 305 aisix-proxy tests + 61 aisix-guardrails tests pass.
Full workspace compiles cleanly with zero warnings.
@coderabbitai

coderabbitaiBot commented May 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 44 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: f6a204c2-56d1-46a1-b4dd-b1f48f39484a

📥 Commits

Reviewing files that changed from the base of the PR and between bec1af1 and e10acc5.

📒 Files selected for processing (1)
  • crates/aisix-guardrails/src/build.rs
📝 Walkthrough

Walkthrough

This PR refactors guardrail configuration from a global statically-wired chain to a per-request index resolved from snapshot attachments. It adds GuardrailAttachment rows, scope-aware priority resolution, input rewrite propagation, live snapshot-backed indexing, and migrates runtime wiring and tests to use snapshot-driven resolution.

Changes

Guardrail Per-Request Resolution

Layer / File(s)Summary
Data model: Guardrail extensions & attachments
crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/snapshot.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/snapshot.rs
Guardrail gains enforcement_mode, mandatory, and direction with serde defaults. Adds GuardrailScopeType and GuardrailAttachment resource and AisixSnapshot.guardrail_attachments. Minor reflow of model re-exports and ResourceTable now derives Clone.
Schema validation and etcd loader
crates/aisix-core/src/models/schema.rs, crates/aisix-etcd/src/loader.rs
Adds guardrail_attachment JSON Schema and validate_guardrail_attachment, and extends the etcd loader to validate/load guardrail_attachments into snapshots.
Index structures and resolution
crates/aisix-guardrails/src/index.rs
Introduces ScopeKind, IndexEntry, RequestContext<'a>, and GuardrailIndex with priority/specificity sorting, applicability matching, deduplication by guardrail_id, and unit tests covering behavior and performance.
Index builder and LiveGuardrailIndex
crates/aisix-guardrails/src/build.rs
Implements build_index_from_snapshot to build pre-sorted index from guardrails+attachments (with enabled filtering and fallback env-scope), and LiveGuardrailIndex that lazily rebuilds with version-checked mutex caching.
Rewrite verdict and chain propagation
crates/aisix-guardrails/src/lib.rs, crates/aisix-guardrails/src/chain.rs
Adds GuardrailVerdict::Rewrite { payload: Box<ChatFormat> } with custom PartialEq and is_rewrite() helper; GuardrailChain::check_input threads payload via Cow<ChatFormat> so rewrites propagate to subsequent guardrails; output rewrites are ignored as no-ops.
Proxy integration: state & dispatch
crates/aisix-proxy/src/state.rs, crates/aisix-proxy/src/chat.rs
Replaces ProxyState.guardrails with guardrail_index: Arc<LiveGuardrailIndex>. dispatch resolves per-request chain using guardrail_index.resolve(RequestContext), handles input rewrites by shadowing the request payload, and uses the resolved chain for streaming and non-streaming output checks.
Server initialization
crates/aisix-server/src/main.rs
Server startup now constructs LiveGuardrailIndex::new(...) and injects it via proxy_state.with_guardrail_index(...) instead of the previous chain API.
Test helpers and migrations
crates/aisix-proxy/src/lib.rs
Adds seed_guardrail test helper to insert guardrail + env attachment into a snapshot; updates guardrail tests to use snapshot-driven seeding rather than manual GuardrailChain construction.
Guardrail JSON Schema updates
schemas/resources/guardrail.schema.json
Guardrail schema updated to include direction (default "both"), enforcement_mode (default "block", allowed "monitor"/"block"), and new mandatory boolean (default false).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Finding 1 (HIGH): Fix misleading doc comment on Guardrail.direction.
The old comment falsely claimed GuardrailIndex::resolve uses the direction
field for routing. Direction-based filtering is not yet implemented; the
existing hook_point field on the guardrail definition already provides
per-hook-point control for keyword rules.
Finding 2 (MEDIUM): Add scope-specificity tiebreaker to index sort.
GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC)
so that equal-priority attachments resolve deterministically: ApiKey wins
over Team, Team over Model, Model over Env. Adds test case 14 to cover
the equal-priority ApiKey > Env dedup scenario.
Finding 3 (MEDIUM): Build LiveGuardrailIndex outside the mutex.
current() now releases the lock before calling build_index_from_snapshot()
so that a panic inside the build function cannot poison the mutex and
crash every subsequent request. A potential concurrent double-build is
accepted as the correct trade-off (both builds produce equivalent results).
Finding 7 (LOW): Remove duplicate doc comment on Guardrail.config field.
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit findings — resolution

Independent audit (full cold review, no shared context) returned 7 findings. All HIGH and MEDIUM findings are addressed in the follow-up commit 5fe0a45. LOW findings disposition below.


Finding 1 — HIGH — Fixed ✅

Guardrail.direction doc comment falsely claimed resolve() uses it for routing.

The comment has been corrected to accurately state that direction-based filtering is not yet implemented in resolve(), and that hook_point on the guardrail definition is the currently-wired mechanism for per-hook-point control.


Finding 2 — MEDIUM — Fixed ✅

Sort used priority only; scope-specificity tiebreaker was missing.

GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC) where ApiKey=3 > Team=2 > Model=1 > Env=0. Added test case 14: equal_priority_apikey_beats_env_in_dedup verifies that an ApiKey-scope entry at priority=50 wins over an Env-scope entry at priority=50 for the same guardrail_id.


Finding 3 — MEDIUM — Fixed ✅

LiveGuardrailIndex::current() held the mutex during build_index_from_snapshot(), risking mutex poisoning on panic.

Refactored to fast-path (lock → version compare → return) + build outside lock + re-acquire to store. A rare concurrent double-build is accepted as correct trade-off; both builds produce equivalent results from the same snapshot version.


Finding 4 — MEDIUM — Justified, not fixed

The three ProxyState constructors eagerly build an initial LiveGuardrailIndex with bedrock_endpoint_url = None, which main.rs immediately discards via with_guardrail_index(). This is a startup-only wasted build; the Bedrock endpoint URL only matters for the index that main.rs wires in. No runtime impact. Will be cleaned up in a follow-up once ProxyState::new() accepts bedrock_endpoint_url as a constructor parameter.


Finding 5 — LOW — Post-merge

Bypass telemetry is lost when a Rewrite also fires in the same chain. No Rewrite guardrail implementor ships yet; the gap is theoretical. A follow-up will add a TODO comment and test asserting the current (lossy) behavior.


Finding 6 — LOW — Post-merge

seed_guardrail() test helper bypasses SnapshotHandle version tracking. Added a doc comment warning (in the follow-up commit) clarifying it must be called before build_state(). A proper fix requiring handle.store() will land in the test harness refactor.


Finding 7 — LOW — Fixed ✅

Duplicate doc comment on Guardrail.config removed.

Add `p0c_fields_dont_trip_keyword_config_deny_unknown_fields` test
that proves enforcement_mode/mandatory/direction are absorbed by the
outer Guardrail struct before the flattened KeywordConfig (which has
deny_unknown_fields) ever sees them. Without this test a regression
in serde field routing would silently disarm P0c field acceptance.
Addresses audit finding (Finding 2) on ai-gateway PR #411.
…clarify PartialEq footgun
Two audit fixes (MEDIUM-1 and MEDIUM-2 from second audit):
1. chain.rs check_input: when Rewrite takes precedence over a Bypass
that fired earlier in the chain, emit a tracing::info! so the bypass
reason is preserved in the audit trail. Previously the bypass was
silently dropped when Cow::Owned(rewritten) matched.
2. lib.rs PartialEq for GuardrailVerdict: strengthen the comment on the
Rewrite == Rewrite arm from a mild note to a WARNING, so future test
authors don't accidentally use assert_eq! and get a permanently-failing
assertion with no helpful error message. Use is_rewrite() instead.
… on multi-attachment guardrails
ResourceTable name-index is a flat map keyed by guardrail_id. A guardrail
with two attachments (e.g. Env-scope + Model-scope) would have the second
insert silently overwrite the first. build_index_from_snapshot already uses
entries() to avoid this, but future callers using get_by_name would silently
lose attachments. Add a WARNING doc comment to make the hazard explicit.
…hema
Three CI failures addressed:
1. lint/fmt — cargo fmt applied to all changed files; the fmt
reformatter touched build.rs, index.rs, proxy/lib.rs and
snapshot.rs (indentation and line-length only).
2. schema drift — ran dump-schema; guardrail.schema.json now
includes the three P0c additive fields (direction,
enforcement_mode, mandatory) introduced in the previous commit.
3. e2e vitest — the GuardrailIndex build path required explicit
GuardrailAttachment rows for any guardrail to fire. Existing
E2E tests create guardrail definitions without attachment rows
(pre-P0c pattern), so the index resolved to an empty chain and
tests timed out waiting for the guardrail to trigger.
Fix: in build_index_from_snapshot, after processing all
attachment rows, iterate the guardrails table and treat any
guardrail with ZERO attachment records as an implicit env-scope
entry at priority 0. This preserves pre-P0c "apply globally"
behavior during the rolling-upgrade window.
Semantic: a guardrail that HAS attachment rows (even all-
disabled) is governed by those rows and does NOT receive the
fallback — the HashSet now tracks all attachment references
regardless of enabled state.
Resolves all 5 findings from the independent audit of commit 4cdfd8f:
HIGH (Finding 3): enforcement_mode="monitor" schema and doc comment
claimed pass-through behavior, but the DP always blocks regardless of
this field. Updated the doc comment with an explicit "not yet
implemented" warning; re-ran dump-schema to propagate to the JSON
schema. Operators who set "monitor" will now see the disclaimer
rather than being silently misled.
MEDIUM (Finding 4): mandatory=true doc comment and schema claimed
fatal-error semantics, but the field is not yet consulted by the
error-path logic. Added "not yet implemented" disclaimer to both the
doc comment and the regenerated schema.
MEDIUM (Finding 2): backward-compat scope-widening was logged at
debug level, invisible in production log streams. Promoted to
tracing::info! and added guardrail_name field so operators can
identify which guardrail is firing globally during the rolling-
upgrade window.
MEDIUM (Finding 1 + 5): two unit tests added:
- no_attachment_guardrail_fires_globally_backward_compat: asserts
that a guardrail with zero attachment rows appears in the index
as an env-scope entry AND blocks matching requests.
- Extended disabled_attachment_is_skipped_in_index: adds a
check_input assertion confirming the guardrail truly does not
fire (not just that index.len() == 0).
HIGH-1: Add test covering the mixed enabled+disabled attachment case —
one_enabled_one_disabled_attachment_fires_exactly_once verifies that a
guardrail with one enabled + one disabled attachment fires exactly once
(via the enabled attachment) and does NOT trigger the backward-compat
env-scope fallback. This pins the HashSet boundary behavior that the
previous commit's comment describes but had no test for.
HIGH-2: Correct the is_empty() doc comment on LiveGuardrailIndex. The
old comment said "no attachment entries" which excluded backward-compat
no-attachment guardrail entries; corrected to "no guardrail entries
from either attachment rows or the backward-compat fallback."
MEDIUM-1: Add TODO(P0c-cleanup) removal marker on the backward-compat
block in build_index_from_snapshot with a link to tracking issue #417.
Prevents the fallback from silently persisting indefinitely after the
rolling-upgrade window closes.
MEDIUM-2: Add tracing::warn! in build_one when enforcement_mode is not
"block". Operators who set enforcement_mode="monitor" expecting pass-
through behavior will now see a log warning that the setting is not yet
implemented and the DP will block regardless.
@moonming
moonming merged commit 98e9835 into mainMay 27, 2026
8 checks passed
@moonming
moonming deleted the feat/p0c-guardrail-index branch May 27, 2026 00:31
jarvis9443 added a commit that referenced this pull request Aug 24, 2026
`mandatory` was never a designed feature. It arrived in #411 as one of
three schema columns the control plane's P0b added, carrying a doc
comment that said so outright: "Not yet implemented — the field is stored
and forwarded to the CP dashboard but the DP does not yet consult it;
`fail_open` alone governs error behavior in the current release." A
behaviour was retro-fitted to it five weeks later in #683, as a follow-up
to a security review.
Its whole documented job was overriding `fail_open` on the failure path.
Once `fail_open` began defaulting to false (#1040), that job was already
done by the default, and only two effects remained: resolving a
configuration the operator contradicted themselves in (`fail_open: true`
plus `mandatory: true`), and punching a hole through
`enforcement_mode: monitor` — which nothing ever specified. It fell out
of decorator ordering, and it read backwards: a monitored row would pass
content it had detected as harmful while refusing all traffic, harmless
included, because its provider was briefly unreachable. The mode meant to
be safe for evaluating a new rule was the one that could take a
deployment down.
Nobody could have been relying on it. The dashboard never exposed the
field, and the control plane is how every user configures aisix.
Monitor mode is unconditional again: a monitored row never blocks, for
any reason. An operator who wants an unreachable provider to refuse
traffic is asking for enforcement, which is `block` mode with
`fail_open: false` — one way to say it instead of two.
This also settles what #1384 asked. A monitored row cannot block, so
`EndOfStreamCheck` is the correct stream policy for it and there is
nothing to hold back.
Removed with it: the `MandatoryGuardrail` decorator, the
`keep_unavailable_fatal` exception #1040 added to `MonitorGuardrail` to
keep this guarantee alive, and the `preserves()` predicate that existed
only to keep the two in agreement.
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c) - #411

Merged
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index
May 27, 2026
Merged

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c)#411
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index

Conversation

@moonming

@moonmingmoonming commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

  • aisix-core: GuardrailAttachment domain model (scope_type, scope_id, priority, enabled, hook_point, direction); GuardrailScopeType enum; AisixSnapshot.guardrail_attachments table; three additive nullable fields on Guardrail (enforcement_mode, mandatory, direction)
  • aisix-etcd: loader syncs guardrail_attachment/ prefix into the snapshot on every tick
  • aisix-guardrails: new index.rsGuardrailIndex + RequestContext + ScopeKind; build_index_from_snapshot(); LiveGuardrailIndex lazy-rebuild adapter; GuardrailVerdict::Rewrite variant with Cow<ChatFormat> propagation through chain; bypass telemetry preserved when shadowed by Rewrite
  • aisix-proxy: ProxyState.guardrail_index: Arc<LiveGuardrailIndex> replaces old flat Arc<dyn Guardrail>; per-request RequestContext constructed from auth context in chat.rs; Rewrite verdict handled for input and output paths; test helper seed_guardrail() exercises the full index-resolution path through a live snapshot handle

Design notes

The index pre-sorts entries by (priority DESC, scope_specificity DESC) at build time. resolve() is a single linear scan with a HashSet dedup by guardrail_id — no allocation on requests with zero applicable entries (is_empty() fast-path). Scope specificity order: ApiKey > Team > Model > Env, matching the P0c spec in #379.

LiveGuardrailIndex follows the same lazy-rebuild pattern as LiveGuardrailChain: one Mutex<IndexCache> holding (last_version, Arc<GuardrailIndex>). Hot path is a ptr-compare against the current snapshot version; full rebuild fires only when the snapshot advances. Build happens outside the lock so a panic during build_index_from_snapshot never poisons the mutex.

Benchmark: 1 000-attachment index build + 100 resolves well under 100ms (included in index.rs tests under criterion).

Serde routing note (critical for CP-DP compat)

KeywordConfig has #[serde(deny_unknown_fields)]. The three P0c fields are declared on the outerGuardrail struct with #[serde(default)], so serde absorbs them at the outer level before the flattened inner type sees the remaining fields. Test p0c_fields_dont_trip_keyword_config_deny_unknown_fields in aisix-core pins this routing: if it ever regressed, the parse would return an unknown-field error and the test would catch it before any merge.

Test plan

  • cargo test -p aisix-core — 176 tests including p0c_fields_dont_trip_keyword_config_deny_unknown_fields
  • cargo test -p aisix-guardrails — 62 tests (index truth-table + build integration + live-rebuild + chain + bedrock + benchmark)
  • cargo check --workspace — zero errors, zero warnings
  • cargo test -p aisix-proxy — proxy integration tests (guardrail block/bypass/rewrite paths via seed_guardrail helper)
  • E2E: local aisix-e2e compose stack — guardrail block/bypass flows with real keyword guardrail config pushed via etcd (tracked in E2E: verify DP handles P0c kine fields (enforcement_mode, mandatory, direction) in real guardrail flow #414)

Closes / related

Part of #379 P0c checklist. AISIX-Cloud PR #516 (kine projection widening) must not merge before this PR is deployed to all DPs.

E2E coverage gap for enforcement_mode/mandatory/direction in a live DP: #414 (filed as follow-up, not blocking merge given unit serde coverage).

Summary by CodeRabbit

  • New Features

    • Guardrail attachments: scope guardrails to env/model/api-key/team with priority ordering
    • Request rewrite verdicts: guardrails can rewrite prompts before processing
    • Guardrail config extended with enforcement_mode, mandatory, and direction fields
  • Improvements

    • Guardrail resolution is per-request (scope+priority) with lazy snapshot-backed updates; streaming and non-streaming paths use the resolved chain

Review Change Stack

…hain (#379 P0c)
Previously the proxy held a single flat Arc<dyn Guardrail> chain that applied
identically to every request. This commit wires in a priority-sorted index that
resolves the correct guardrail chain per-request based on attachment scope
(env / model / api-key / team) and priority, enabling fine-grained, tenant-aware
content control without any hot-path allocation on requests with no guardrails.
## aisix-core
- GuardrailAttachment domain model (guardrail_id, scope_type, scope_id, priority,
enabled, hook_point, direction) with full serde round-trip
- GuardrailScopeType enum (Env/Model/ApiKey/Team)
- AisixSnapshot gains guardrail_attachments ResourceTable
- Guardrail domain model: three additive optional fields
(enforcement_mode, mandatory, direction) — nullable, backward-compat
## aisix-etcd
- loader: load "guardrail_attachment/" prefix and populate
snapshot.guardrail_attachments on every sync
## aisix-guardrails
- index.rs: GuardrailIndex + RequestContext + ScopeKind
- Entries pre-sorted by (scope_specificity DESC, priority DESC)
- resolve() walks entries in one pass, deduplicates by guardrail_id
(highest-priority scope wins), returns a GuardrailChain
- is_empty() fast-path skips chain allocation on requests with no applicable rules
- 13 truth-table unit tests + 1 benchmark (1000-attachment build +
100 resolves in < 100ms under criterion)
- build.rs: build_index_from_snapshot() joins guardrails + guardrail_attachments
tables, skips disabled rows, builds runtime guardrails per attachment
- build.rs: LiveGuardrailIndex — lazy-rebuild adapter over SnapshotHandle;
one mutex + ptr-compare on hot path; full rebuild only when snapshot version
changes (same pattern as LiveGuardrailChain)
- chain.rs: GuardrailVerdict::Rewrite variant + Cow<ChatFormat> propagation
through chain; treated as Allow on the output path
## aisix-proxy
- state.rs: ProxyState.guardrail_index: Arc<LiveGuardrailIndex>
replaces the old Arc<dyn Guardrail> field; all three constructors
initialize a default empty index; with_guardrail_index() replaces
with_guardrails()
- chat.rs: per-request RequestContext + resolved_chain before input check;
Rewrite verdict handled for both input and output paths
- lib.rs (tests): seed_guardrail() helper inserts guardrail definition +
env-scope attachment into AisixSnapshot so tests exercise the full
index-resolution path through the live snapshot handle; all five
guardrail test sites updated
## aisix-server
- bootstrap: proxy_state.with_guardrail_index(LiveGuardrailIndex::new(
snapshot_handle.clone(), bedrock_endpoint_url))
All 305 aisix-proxy tests + 61 aisix-guardrails tests pass.
Full workspace compiles cleanly with zero warnings.
@coderabbitai

coderabbitaiBot commented May 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 44 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: f6a204c2-56d1-46a1-b4dd-b1f48f39484a

📥 Commits

Reviewing files that changed from the base of the PR and between bec1af1 and e10acc5.

📒 Files selected for processing (1)
  • crates/aisix-guardrails/src/build.rs
📝 Walkthrough

Walkthrough

This PR refactors guardrail configuration from a global statically-wired chain to a per-request index resolved from snapshot attachments. It adds GuardrailAttachment rows, scope-aware priority resolution, input rewrite propagation, live snapshot-backed indexing, and migrates runtime wiring and tests to use snapshot-driven resolution.

Changes

Guardrail Per-Request Resolution

Layer / File(s)Summary
Data model: Guardrail extensions & attachments
crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/snapshot.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/snapshot.rs
Guardrail gains enforcement_mode, mandatory, and direction with serde defaults. Adds GuardrailScopeType and GuardrailAttachment resource and AisixSnapshot.guardrail_attachments. Minor reflow of model re-exports and ResourceTable now derives Clone.
Schema validation and etcd loader
crates/aisix-core/src/models/schema.rs, crates/aisix-etcd/src/loader.rs
Adds guardrail_attachment JSON Schema and validate_guardrail_attachment, and extends the etcd loader to validate/load guardrail_attachments into snapshots.
Index structures and resolution
crates/aisix-guardrails/src/index.rs
Introduces ScopeKind, IndexEntry, RequestContext<'a>, and GuardrailIndex with priority/specificity sorting, applicability matching, deduplication by guardrail_id, and unit tests covering behavior and performance.
Index builder and LiveGuardrailIndex
crates/aisix-guardrails/src/build.rs
Implements build_index_from_snapshot to build pre-sorted index from guardrails+attachments (with enabled filtering and fallback env-scope), and LiveGuardrailIndex that lazily rebuilds with version-checked mutex caching.
Rewrite verdict and chain propagation
crates/aisix-guardrails/src/lib.rs, crates/aisix-guardrails/src/chain.rs
Adds GuardrailVerdict::Rewrite { payload: Box<ChatFormat> } with custom PartialEq and is_rewrite() helper; GuardrailChain::check_input threads payload via Cow<ChatFormat> so rewrites propagate to subsequent guardrails; output rewrites are ignored as no-ops.
Proxy integration: state & dispatch
crates/aisix-proxy/src/state.rs, crates/aisix-proxy/src/chat.rs
Replaces ProxyState.guardrails with guardrail_index: Arc<LiveGuardrailIndex>. dispatch resolves per-request chain using guardrail_index.resolve(RequestContext), handles input rewrites by shadowing the request payload, and uses the resolved chain for streaming and non-streaming output checks.
Server initialization
crates/aisix-server/src/main.rs
Server startup now constructs LiveGuardrailIndex::new(...) and injects it via proxy_state.with_guardrail_index(...) instead of the previous chain API.
Test helpers and migrations
crates/aisix-proxy/src/lib.rs
Adds seed_guardrail test helper to insert guardrail + env attachment into a snapshot; updates guardrail tests to use snapshot-driven seeding rather than manual GuardrailChain construction.
Guardrail JSON Schema updates
schemas/resources/guardrail.schema.json
Guardrail schema updated to include direction (default "both"), enforcement_mode (default "block", allowed "monitor"/"block"), and new mandatory boolean (default false).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Finding 1 (HIGH): Fix misleading doc comment on Guardrail.direction.
The old comment falsely claimed GuardrailIndex::resolve uses the direction
field for routing. Direction-based filtering is not yet implemented; the
existing hook_point field on the guardrail definition already provides
per-hook-point control for keyword rules.
Finding 2 (MEDIUM): Add scope-specificity tiebreaker to index sort.
GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC)
so that equal-priority attachments resolve deterministically: ApiKey wins
over Team, Team over Model, Model over Env. Adds test case 14 to cover
the equal-priority ApiKey > Env dedup scenario.
Finding 3 (MEDIUM): Build LiveGuardrailIndex outside the mutex.
current() now releases the lock before calling build_index_from_snapshot()
so that a panic inside the build function cannot poison the mutex and
crash every subsequent request. A potential concurrent double-build is
accepted as the correct trade-off (both builds produce equivalent results).
Finding 7 (LOW): Remove duplicate doc comment on Guardrail.config field.
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit findings — resolution

Independent audit (full cold review, no shared context) returned 7 findings. All HIGH and MEDIUM findings are addressed in the follow-up commit 5fe0a45. LOW findings disposition below.


Finding 1 — HIGH — Fixed ✅

Guardrail.direction doc comment falsely claimed resolve() uses it for routing.

The comment has been corrected to accurately state that direction-based filtering is not yet implemented in resolve(), and that hook_point on the guardrail definition is the currently-wired mechanism for per-hook-point control.


Finding 2 — MEDIUM — Fixed ✅

Sort used priority only; scope-specificity tiebreaker was missing.

GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC) where ApiKey=3 > Team=2 > Model=1 > Env=0. Added test case 14: equal_priority_apikey_beats_env_in_dedup verifies that an ApiKey-scope entry at priority=50 wins over an Env-scope entry at priority=50 for the same guardrail_id.


Finding 3 — MEDIUM — Fixed ✅

LiveGuardrailIndex::current() held the mutex during build_index_from_snapshot(), risking mutex poisoning on panic.

Refactored to fast-path (lock → version compare → return) + build outside lock + re-acquire to store. A rare concurrent double-build is accepted as correct trade-off; both builds produce equivalent results from the same snapshot version.


Finding 4 — MEDIUM — Justified, not fixed

The three ProxyState constructors eagerly build an initial LiveGuardrailIndex with bedrock_endpoint_url = None, which main.rs immediately discards via with_guardrail_index(). This is a startup-only wasted build; the Bedrock endpoint URL only matters for the index that main.rs wires in. No runtime impact. Will be cleaned up in a follow-up once ProxyState::new() accepts bedrock_endpoint_url as a constructor parameter.


Finding 5 — LOW — Post-merge

Bypass telemetry is lost when a Rewrite also fires in the same chain. No Rewrite guardrail implementor ships yet; the gap is theoretical. A follow-up will add a TODO comment and test asserting the current (lossy) behavior.


Finding 6 — LOW — Post-merge

seed_guardrail() test helper bypasses SnapshotHandle version tracking. Added a doc comment warning (in the follow-up commit) clarifying it must be called before build_state(). A proper fix requiring handle.store() will land in the test harness refactor.


Finding 7 — LOW — Fixed ✅

Duplicate doc comment on Guardrail.config removed.

Add `p0c_fields_dont_trip_keyword_config_deny_unknown_fields` test
that proves enforcement_mode/mandatory/direction are absorbed by the
outer Guardrail struct before the flattened KeywordConfig (which has
deny_unknown_fields) ever sees them. Without this test a regression
in serde field routing would silently disarm P0c field acceptance.
Addresses audit finding (Finding 2) on ai-gateway PR #411.
…clarify PartialEq footgun
Two audit fixes (MEDIUM-1 and MEDIUM-2 from second audit):
1. chain.rs check_input: when Rewrite takes precedence over a Bypass
that fired earlier in the chain, emit a tracing::info! so the bypass
reason is preserved in the audit trail. Previously the bypass was
silently dropped when Cow::Owned(rewritten) matched.
2. lib.rs PartialEq for GuardrailVerdict: strengthen the comment on the
Rewrite == Rewrite arm from a mild note to a WARNING, so future test
authors don't accidentally use assert_eq! and get a permanently-failing
assertion with no helpful error message. Use is_rewrite() instead.
… on multi-attachment guardrails
ResourceTable name-index is a flat map keyed by guardrail_id. A guardrail
with two attachments (e.g. Env-scope + Model-scope) would have the second
insert silently overwrite the first. build_index_from_snapshot already uses
entries() to avoid this, but future callers using get_by_name would silently
lose attachments. Add a WARNING doc comment to make the hazard explicit.
…hema
Three CI failures addressed:
1. lint/fmt — cargo fmt applied to all changed files; the fmt
reformatter touched build.rs, index.rs, proxy/lib.rs and
snapshot.rs (indentation and line-length only).
2. schema drift — ran dump-schema; guardrail.schema.json now
includes the three P0c additive fields (direction,
enforcement_mode, mandatory) introduced in the previous commit.
3. e2e vitest — the GuardrailIndex build path required explicit
GuardrailAttachment rows for any guardrail to fire. Existing
E2E tests create guardrail definitions without attachment rows
(pre-P0c pattern), so the index resolved to an empty chain and
tests timed out waiting for the guardrail to trigger.
Fix: in build_index_from_snapshot, after processing all
attachment rows, iterate the guardrails table and treat any
guardrail with ZERO attachment records as an implicit env-scope
entry at priority 0. This preserves pre-P0c "apply globally"
behavior during the rolling-upgrade window.
Semantic: a guardrail that HAS attachment rows (even all-
disabled) is governed by those rows and does NOT receive the
fallback — the HashSet now tracks all attachment references
regardless of enabled state.
Resolves all 5 findings from the independent audit of commit 4cdfd8f:
HIGH (Finding 3): enforcement_mode="monitor" schema and doc comment
claimed pass-through behavior, but the DP always blocks regardless of
this field. Updated the doc comment with an explicit "not yet
implemented" warning; re-ran dump-schema to propagate to the JSON
schema. Operators who set "monitor" will now see the disclaimer
rather than being silently misled.
MEDIUM (Finding 4): mandatory=true doc comment and schema claimed
fatal-error semantics, but the field is not yet consulted by the
error-path logic. Added "not yet implemented" disclaimer to both the
doc comment and the regenerated schema.
MEDIUM (Finding 2): backward-compat scope-widening was logged at
debug level, invisible in production log streams. Promoted to
tracing::info! and added guardrail_name field so operators can
identify which guardrail is firing globally during the rolling-
upgrade window.
MEDIUM (Finding 1 + 5): two unit tests added:
- no_attachment_guardrail_fires_globally_backward_compat: asserts
that a guardrail with zero attachment rows appears in the index
as an env-scope entry AND blocks matching requests.
- Extended disabled_attachment_is_skipped_in_index: adds a
check_input assertion confirming the guardrail truly does not
fire (not just that index.len() == 0).
HIGH-1: Add test covering the mixed enabled+disabled attachment case —
one_enabled_one_disabled_attachment_fires_exactly_once verifies that a
guardrail with one enabled + one disabled attachment fires exactly once
(via the enabled attachment) and does NOT trigger the backward-compat
env-scope fallback. This pins the HashSet boundary behavior that the
previous commit's comment describes but had no test for.
HIGH-2: Correct the is_empty() doc comment on LiveGuardrailIndex. The
old comment said "no attachment entries" which excluded backward-compat
no-attachment guardrail entries; corrected to "no guardrail entries
from either attachment rows or the backward-compat fallback."
MEDIUM-1: Add TODO(P0c-cleanup) removal marker on the backward-compat
block in build_index_from_snapshot with a link to tracking issue #417.
Prevents the fallback from silently persisting indefinitely after the
rolling-upgrade window closes.
MEDIUM-2: Add tracing::warn! in build_one when enforcement_mode is not
"block". Operators who set enforcement_mode="monitor" expecting pass-
through behavior will now see a log warning that the setting is not yet
implemented and the DP will block regardless.
@moonming
moonming merged commit 98e9835 into mainMay 27, 2026
8 checks passed
@moonming
moonming deleted the feat/p0c-guardrail-index branch May 27, 2026 00:31
jarvis9443 added a commit that referenced this pull request Aug 24, 2026
`mandatory` was never a designed feature. It arrived in #411 as one of
three schema columns the control plane's P0b added, carrying a doc
comment that said so outright: "Not yet implemented — the field is stored
and forwarded to the CP dashboard but the DP does not yet consult it;
`fail_open` alone governs error behavior in the current release." A
behaviour was retro-fitted to it five weeks later in #683, as a follow-up
to a security review.
Its whole documented job was overriding `fail_open` on the failure path.
Once `fail_open` began defaulting to false (#1040), that job was already
done by the default, and only two effects remained: resolving a
configuration the operator contradicted themselves in (`fail_open: true`
plus `mandatory: true`), and punching a hole through
`enforcement_mode: monitor` — which nothing ever specified. It fell out
of decorator ordering, and it read backwards: a monitored row would pass
content it had detected as harmful while refusing all traffic, harmless
included, because its provider was briefly unreachable. The mode meant to
be safe for evaluating a new rule was the one that could take a
deployment down.
Nobody could have been relying on it. The dashboard never exposed the
field, and the control plane is how every user configures aisix.
Monitor mode is unconditional again: a monitored row never blocks, for
any reason. An operator who wants an unreachable provider to refuse
traffic is asking for enforcement, which is `block` mode with
`fail_open: false` — one way to say it instead of two.
This also settles what #1384 asked. A monitored row cannot block, so
`EndOfStreamCheck` is the correct stream policy for it and there is
nothing to hold back.
Removed with it: the `MandatoryGuardrail` decorator, the
`keep_unavailable_fatal` exception #1040 added to `MonitorGuardrail` to
keep this guarantee alive, and the `preserves()` predicate that existed
only to keep the two in agreement.
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c) - #411

Merged
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index
May 27, 2026
Merged

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c)#411
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index

Conversation

@moonming

@moonmingmoonming commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

  • aisix-core: GuardrailAttachment domain model (scope_type, scope_id, priority, enabled, hook_point, direction); GuardrailScopeType enum; AisixSnapshot.guardrail_attachments table; three additive nullable fields on Guardrail (enforcement_mode, mandatory, direction)
  • aisix-etcd: loader syncs guardrail_attachment/ prefix into the snapshot on every tick
  • aisix-guardrails: new index.rsGuardrailIndex + RequestContext + ScopeKind; build_index_from_snapshot(); LiveGuardrailIndex lazy-rebuild adapter; GuardrailVerdict::Rewrite variant with Cow<ChatFormat> propagation through chain; bypass telemetry preserved when shadowed by Rewrite
  • aisix-proxy: ProxyState.guardrail_index: Arc<LiveGuardrailIndex> replaces old flat Arc<dyn Guardrail>; per-request RequestContext constructed from auth context in chat.rs; Rewrite verdict handled for input and output paths; test helper seed_guardrail() exercises the full index-resolution path through a live snapshot handle

Design notes

The index pre-sorts entries by (priority DESC, scope_specificity DESC) at build time. resolve() is a single linear scan with a HashSet dedup by guardrail_id — no allocation on requests with zero applicable entries (is_empty() fast-path). Scope specificity order: ApiKey > Team > Model > Env, matching the P0c spec in #379.

LiveGuardrailIndex follows the same lazy-rebuild pattern as LiveGuardrailChain: one Mutex<IndexCache> holding (last_version, Arc<GuardrailIndex>). Hot path is a ptr-compare against the current snapshot version; full rebuild fires only when the snapshot advances. Build happens outside the lock so a panic during build_index_from_snapshot never poisons the mutex.

Benchmark: 1 000-attachment index build + 100 resolves well under 100ms (included in index.rs tests under criterion).

Serde routing note (critical for CP-DP compat)

KeywordConfig has #[serde(deny_unknown_fields)]. The three P0c fields are declared on the outerGuardrail struct with #[serde(default)], so serde absorbs them at the outer level before the flattened inner type sees the remaining fields. Test p0c_fields_dont_trip_keyword_config_deny_unknown_fields in aisix-core pins this routing: if it ever regressed, the parse would return an unknown-field error and the test would catch it before any merge.

Test plan

  • cargo test -p aisix-core — 176 tests including p0c_fields_dont_trip_keyword_config_deny_unknown_fields
  • cargo test -p aisix-guardrails — 62 tests (index truth-table + build integration + live-rebuild + chain + bedrock + benchmark)
  • cargo check --workspace — zero errors, zero warnings
  • cargo test -p aisix-proxy — proxy integration tests (guardrail block/bypass/rewrite paths via seed_guardrail helper)
  • E2E: local aisix-e2e compose stack — guardrail block/bypass flows with real keyword guardrail config pushed via etcd (tracked in E2E: verify DP handles P0c kine fields (enforcement_mode, mandatory, direction) in real guardrail flow #414)

Closes / related

Part of #379 P0c checklist. AISIX-Cloud PR #516 (kine projection widening) must not merge before this PR is deployed to all DPs.

E2E coverage gap for enforcement_mode/mandatory/direction in a live DP: #414 (filed as follow-up, not blocking merge given unit serde coverage).

Summary by CodeRabbit

  • New Features

    • Guardrail attachments: scope guardrails to env/model/api-key/team with priority ordering
    • Request rewrite verdicts: guardrails can rewrite prompts before processing
    • Guardrail config extended with enforcement_mode, mandatory, and direction fields
  • Improvements

    • Guardrail resolution is per-request (scope+priority) with lazy snapshot-backed updates; streaming and non-streaming paths use the resolved chain

Review Change Stack

…hain (#379 P0c)
Previously the proxy held a single flat Arc<dyn Guardrail> chain that applied
identically to every request. This commit wires in a priority-sorted index that
resolves the correct guardrail chain per-request based on attachment scope
(env / model / api-key / team) and priority, enabling fine-grained, tenant-aware
content control without any hot-path allocation on requests with no guardrails.
## aisix-core
- GuardrailAttachment domain model (guardrail_id, scope_type, scope_id, priority,
enabled, hook_point, direction) with full serde round-trip
- GuardrailScopeType enum (Env/Model/ApiKey/Team)
- AisixSnapshot gains guardrail_attachments ResourceTable
- Guardrail domain model: three additive optional fields
(enforcement_mode, mandatory, direction) — nullable, backward-compat
## aisix-etcd
- loader: load "guardrail_attachment/" prefix and populate
snapshot.guardrail_attachments on every sync
## aisix-guardrails
- index.rs: GuardrailIndex + RequestContext + ScopeKind
- Entries pre-sorted by (scope_specificity DESC, priority DESC)
- resolve() walks entries in one pass, deduplicates by guardrail_id
(highest-priority scope wins), returns a GuardrailChain
- is_empty() fast-path skips chain allocation on requests with no applicable rules
- 13 truth-table unit tests + 1 benchmark (1000-attachment build +
100 resolves in < 100ms under criterion)
- build.rs: build_index_from_snapshot() joins guardrails + guardrail_attachments
tables, skips disabled rows, builds runtime guardrails per attachment
- build.rs: LiveGuardrailIndex — lazy-rebuild adapter over SnapshotHandle;
one mutex + ptr-compare on hot path; full rebuild only when snapshot version
changes (same pattern as LiveGuardrailChain)
- chain.rs: GuardrailVerdict::Rewrite variant + Cow<ChatFormat> propagation
through chain; treated as Allow on the output path
## aisix-proxy
- state.rs: ProxyState.guardrail_index: Arc<LiveGuardrailIndex>
replaces the old Arc<dyn Guardrail> field; all three constructors
initialize a default empty index; with_guardrail_index() replaces
with_guardrails()
- chat.rs: per-request RequestContext + resolved_chain before input check;
Rewrite verdict handled for both input and output paths
- lib.rs (tests): seed_guardrail() helper inserts guardrail definition +
env-scope attachment into AisixSnapshot so tests exercise the full
index-resolution path through the live snapshot handle; all five
guardrail test sites updated
## aisix-server
- bootstrap: proxy_state.with_guardrail_index(LiveGuardrailIndex::new(
snapshot_handle.clone(), bedrock_endpoint_url))
All 305 aisix-proxy tests + 61 aisix-guardrails tests pass.
Full workspace compiles cleanly with zero warnings.
@coderabbitai

coderabbitaiBot commented May 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 44 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: f6a204c2-56d1-46a1-b4dd-b1f48f39484a

📥 Commits

Reviewing files that changed from the base of the PR and between bec1af1 and e10acc5.

📒 Files selected for processing (1)
  • crates/aisix-guardrails/src/build.rs
📝 Walkthrough

Walkthrough

This PR refactors guardrail configuration from a global statically-wired chain to a per-request index resolved from snapshot attachments. It adds GuardrailAttachment rows, scope-aware priority resolution, input rewrite propagation, live snapshot-backed indexing, and migrates runtime wiring and tests to use snapshot-driven resolution.

Changes

Guardrail Per-Request Resolution

Layer / File(s)Summary
Data model: Guardrail extensions & attachments
crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/snapshot.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/snapshot.rs
Guardrail gains enforcement_mode, mandatory, and direction with serde defaults. Adds GuardrailScopeType and GuardrailAttachment resource and AisixSnapshot.guardrail_attachments. Minor reflow of model re-exports and ResourceTable now derives Clone.
Schema validation and etcd loader
crates/aisix-core/src/models/schema.rs, crates/aisix-etcd/src/loader.rs
Adds guardrail_attachment JSON Schema and validate_guardrail_attachment, and extends the etcd loader to validate/load guardrail_attachments into snapshots.
Index structures and resolution
crates/aisix-guardrails/src/index.rs
Introduces ScopeKind, IndexEntry, RequestContext<'a>, and GuardrailIndex with priority/specificity sorting, applicability matching, deduplication by guardrail_id, and unit tests covering behavior and performance.
Index builder and LiveGuardrailIndex
crates/aisix-guardrails/src/build.rs
Implements build_index_from_snapshot to build pre-sorted index from guardrails+attachments (with enabled filtering and fallback env-scope), and LiveGuardrailIndex that lazily rebuilds with version-checked mutex caching.
Rewrite verdict and chain propagation
crates/aisix-guardrails/src/lib.rs, crates/aisix-guardrails/src/chain.rs
Adds GuardrailVerdict::Rewrite { payload: Box<ChatFormat> } with custom PartialEq and is_rewrite() helper; GuardrailChain::check_input threads payload via Cow<ChatFormat> so rewrites propagate to subsequent guardrails; output rewrites are ignored as no-ops.
Proxy integration: state & dispatch
crates/aisix-proxy/src/state.rs, crates/aisix-proxy/src/chat.rs
Replaces ProxyState.guardrails with guardrail_index: Arc<LiveGuardrailIndex>. dispatch resolves per-request chain using guardrail_index.resolve(RequestContext), handles input rewrites by shadowing the request payload, and uses the resolved chain for streaming and non-streaming output checks.
Server initialization
crates/aisix-server/src/main.rs
Server startup now constructs LiveGuardrailIndex::new(...) and injects it via proxy_state.with_guardrail_index(...) instead of the previous chain API.
Test helpers and migrations
crates/aisix-proxy/src/lib.rs
Adds seed_guardrail test helper to insert guardrail + env attachment into a snapshot; updates guardrail tests to use snapshot-driven seeding rather than manual GuardrailChain construction.
Guardrail JSON Schema updates
schemas/resources/guardrail.schema.json
Guardrail schema updated to include direction (default "both"), enforcement_mode (default "block", allowed "monitor"/"block"), and new mandatory boolean (default false).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Finding 1 (HIGH): Fix misleading doc comment on Guardrail.direction.
The old comment falsely claimed GuardrailIndex::resolve uses the direction
field for routing. Direction-based filtering is not yet implemented; the
existing hook_point field on the guardrail definition already provides
per-hook-point control for keyword rules.
Finding 2 (MEDIUM): Add scope-specificity tiebreaker to index sort.
GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC)
so that equal-priority attachments resolve deterministically: ApiKey wins
over Team, Team over Model, Model over Env. Adds test case 14 to cover
the equal-priority ApiKey > Env dedup scenario.
Finding 3 (MEDIUM): Build LiveGuardrailIndex outside the mutex.
current() now releases the lock before calling build_index_from_snapshot()
so that a panic inside the build function cannot poison the mutex and
crash every subsequent request. A potential concurrent double-build is
accepted as the correct trade-off (both builds produce equivalent results).
Finding 7 (LOW): Remove duplicate doc comment on Guardrail.config field.
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit findings — resolution

Independent audit (full cold review, no shared context) returned 7 findings. All HIGH and MEDIUM findings are addressed in the follow-up commit 5fe0a45. LOW findings disposition below.


Finding 1 — HIGH — Fixed ✅

Guardrail.direction doc comment falsely claimed resolve() uses it for routing.

The comment has been corrected to accurately state that direction-based filtering is not yet implemented in resolve(), and that hook_point on the guardrail definition is the currently-wired mechanism for per-hook-point control.


Finding 2 — MEDIUM — Fixed ✅

Sort used priority only; scope-specificity tiebreaker was missing.

GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC) where ApiKey=3 > Team=2 > Model=1 > Env=0. Added test case 14: equal_priority_apikey_beats_env_in_dedup verifies that an ApiKey-scope entry at priority=50 wins over an Env-scope entry at priority=50 for the same guardrail_id.


Finding 3 — MEDIUM — Fixed ✅

LiveGuardrailIndex::current() held the mutex during build_index_from_snapshot(), risking mutex poisoning on panic.

Refactored to fast-path (lock → version compare → return) + build outside lock + re-acquire to store. A rare concurrent double-build is accepted as correct trade-off; both builds produce equivalent results from the same snapshot version.


Finding 4 — MEDIUM — Justified, not fixed

The three ProxyState constructors eagerly build an initial LiveGuardrailIndex with bedrock_endpoint_url = None, which main.rs immediately discards via with_guardrail_index(). This is a startup-only wasted build; the Bedrock endpoint URL only matters for the index that main.rs wires in. No runtime impact. Will be cleaned up in a follow-up once ProxyState::new() accepts bedrock_endpoint_url as a constructor parameter.


Finding 5 — LOW — Post-merge

Bypass telemetry is lost when a Rewrite also fires in the same chain. No Rewrite guardrail implementor ships yet; the gap is theoretical. A follow-up will add a TODO comment and test asserting the current (lossy) behavior.


Finding 6 — LOW — Post-merge

seed_guardrail() test helper bypasses SnapshotHandle version tracking. Added a doc comment warning (in the follow-up commit) clarifying it must be called before build_state(). A proper fix requiring handle.store() will land in the test harness refactor.


Finding 7 — LOW — Fixed ✅

Duplicate doc comment on Guardrail.config removed.

Add `p0c_fields_dont_trip_keyword_config_deny_unknown_fields` test
that proves enforcement_mode/mandatory/direction are absorbed by the
outer Guardrail struct before the flattened KeywordConfig (which has
deny_unknown_fields) ever sees them. Without this test a regression
in serde field routing would silently disarm P0c field acceptance.
Addresses audit finding (Finding 2) on ai-gateway PR #411.
…clarify PartialEq footgun
Two audit fixes (MEDIUM-1 and MEDIUM-2 from second audit):
1. chain.rs check_input: when Rewrite takes precedence over a Bypass
that fired earlier in the chain, emit a tracing::info! so the bypass
reason is preserved in the audit trail. Previously the bypass was
silently dropped when Cow::Owned(rewritten) matched.
2. lib.rs PartialEq for GuardrailVerdict: strengthen the comment on the
Rewrite == Rewrite arm from a mild note to a WARNING, so future test
authors don't accidentally use assert_eq! and get a permanently-failing
assertion with no helpful error message. Use is_rewrite() instead.
… on multi-attachment guardrails
ResourceTable name-index is a flat map keyed by guardrail_id. A guardrail
with two attachments (e.g. Env-scope + Model-scope) would have the second
insert silently overwrite the first. build_index_from_snapshot already uses
entries() to avoid this, but future callers using get_by_name would silently
lose attachments. Add a WARNING doc comment to make the hazard explicit.
…hema
Three CI failures addressed:
1. lint/fmt — cargo fmt applied to all changed files; the fmt
reformatter touched build.rs, index.rs, proxy/lib.rs and
snapshot.rs (indentation and line-length only).
2. schema drift — ran dump-schema; guardrail.schema.json now
includes the three P0c additive fields (direction,
enforcement_mode, mandatory) introduced in the previous commit.
3. e2e vitest — the GuardrailIndex build path required explicit
GuardrailAttachment rows for any guardrail to fire. Existing
E2E tests create guardrail definitions without attachment rows
(pre-P0c pattern), so the index resolved to an empty chain and
tests timed out waiting for the guardrail to trigger.
Fix: in build_index_from_snapshot, after processing all
attachment rows, iterate the guardrails table and treat any
guardrail with ZERO attachment records as an implicit env-scope
entry at priority 0. This preserves pre-P0c "apply globally"
behavior during the rolling-upgrade window.
Semantic: a guardrail that HAS attachment rows (even all-
disabled) is governed by those rows and does NOT receive the
fallback — the HashSet now tracks all attachment references
regardless of enabled state.
Resolves all 5 findings from the independent audit of commit 4cdfd8f:
HIGH (Finding 3): enforcement_mode="monitor" schema and doc comment
claimed pass-through behavior, but the DP always blocks regardless of
this field. Updated the doc comment with an explicit "not yet
implemented" warning; re-ran dump-schema to propagate to the JSON
schema. Operators who set "monitor" will now see the disclaimer
rather than being silently misled.
MEDIUM (Finding 4): mandatory=true doc comment and schema claimed
fatal-error semantics, but the field is not yet consulted by the
error-path logic. Added "not yet implemented" disclaimer to both the
doc comment and the regenerated schema.
MEDIUM (Finding 2): backward-compat scope-widening was logged at
debug level, invisible in production log streams. Promoted to
tracing::info! and added guardrail_name field so operators can
identify which guardrail is firing globally during the rolling-
upgrade window.
MEDIUM (Finding 1 + 5): two unit tests added:
- no_attachment_guardrail_fires_globally_backward_compat: asserts
that a guardrail with zero attachment rows appears in the index
as an env-scope entry AND blocks matching requests.
- Extended disabled_attachment_is_skipped_in_index: adds a
check_input assertion confirming the guardrail truly does not
fire (not just that index.len() == 0).
HIGH-1: Add test covering the mixed enabled+disabled attachment case —
one_enabled_one_disabled_attachment_fires_exactly_once verifies that a
guardrail with one enabled + one disabled attachment fires exactly once
(via the enabled attachment) and does NOT trigger the backward-compat
env-scope fallback. This pins the HashSet boundary behavior that the
previous commit's comment describes but had no test for.
HIGH-2: Correct the is_empty() doc comment on LiveGuardrailIndex. The
old comment said "no attachment entries" which excluded backward-compat
no-attachment guardrail entries; corrected to "no guardrail entries
from either attachment rows or the backward-compat fallback."
MEDIUM-1: Add TODO(P0c-cleanup) removal marker on the backward-compat
block in build_index_from_snapshot with a link to tracking issue #417.
Prevents the fallback from silently persisting indefinitely after the
rolling-upgrade window closes.
MEDIUM-2: Add tracing::warn! in build_one when enforcement_mode is not
"block". Operators who set enforcement_mode="monitor" expecting pass-
through behavior will now see a log warning that the setting is not yet
implemented and the DP will block regardless.
@moonming
moonming merged commit 98e9835 into mainMay 27, 2026
8 checks passed
@moonming
moonming deleted the feat/p0c-guardrail-index branch May 27, 2026 00:31
jarvis9443 added a commit that referenced this pull request Aug 24, 2026
`mandatory` was never a designed feature. It arrived in #411 as one of
three schema columns the control plane's P0b added, carrying a doc
comment that said so outright: "Not yet implemented — the field is stored
and forwarded to the CP dashboard but the DP does not yet consult it;
`fail_open` alone governs error behavior in the current release." A
behaviour was retro-fitted to it five weeks later in #683, as a follow-up
to a security review.
Its whole documented job was overriding `fail_open` on the failure path.
Once `fail_open` began defaulting to false (#1040), that job was already
done by the default, and only two effects remained: resolving a
configuration the operator contradicted themselves in (`fail_open: true`
plus `mandatory: true`), and punching a hole through
`enforcement_mode: monitor` — which nothing ever specified. It fell out
of decorator ordering, and it read backwards: a monitored row would pass
content it had detected as harmful while refusing all traffic, harmless
included, because its provider was briefly unreachable. The mode meant to
be safe for evaluating a new rule was the one that could take a
deployment down.
Nobody could have been relying on it. The dashboard never exposed the
field, and the control plane is how every user configures aisix.
Monitor mode is unconditional again: a monitored row never blocks, for
any reason. An operator who wants an unreachable provider to refuse
traffic is asking for enforcement, which is `block` mode with
`fail_open: false` — one way to say it instead of two.
This also settles what #1384 asked. A monitored row cannot block, so
`EndOfStreamCheck` is the correct stream policy for it and there is
nothing to hold back.
Removed with it: the `MandatoryGuardrail` decorator, the
`keep_unavailable_fatal` exception #1040 added to `MonitorGuardrail` to
keep this guarantee alive, and the `preserves()` predicate that existed
only to keep the two in agreement.
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c) - #411

Merged
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index
May 27, 2026
Merged

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c)#411
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index

Conversation

@moonming

@moonmingmoonming commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

  • aisix-core: GuardrailAttachment domain model (scope_type, scope_id, priority, enabled, hook_point, direction); GuardrailScopeType enum; AisixSnapshot.guardrail_attachments table; three additive nullable fields on Guardrail (enforcement_mode, mandatory, direction)
  • aisix-etcd: loader syncs guardrail_attachment/ prefix into the snapshot on every tick
  • aisix-guardrails: new index.rsGuardrailIndex + RequestContext + ScopeKind; build_index_from_snapshot(); LiveGuardrailIndex lazy-rebuild adapter; GuardrailVerdict::Rewrite variant with Cow<ChatFormat> propagation through chain; bypass telemetry preserved when shadowed by Rewrite
  • aisix-proxy: ProxyState.guardrail_index: Arc<LiveGuardrailIndex> replaces old flat Arc<dyn Guardrail>; per-request RequestContext constructed from auth context in chat.rs; Rewrite verdict handled for input and output paths; test helper seed_guardrail() exercises the full index-resolution path through a live snapshot handle

Design notes

The index pre-sorts entries by (priority DESC, scope_specificity DESC) at build time. resolve() is a single linear scan with a HashSet dedup by guardrail_id — no allocation on requests with zero applicable entries (is_empty() fast-path). Scope specificity order: ApiKey > Team > Model > Env, matching the P0c spec in #379.

LiveGuardrailIndex follows the same lazy-rebuild pattern as LiveGuardrailChain: one Mutex<IndexCache> holding (last_version, Arc<GuardrailIndex>). Hot path is a ptr-compare against the current snapshot version; full rebuild fires only when the snapshot advances. Build happens outside the lock so a panic during build_index_from_snapshot never poisons the mutex.

Benchmark: 1 000-attachment index build + 100 resolves well under 100ms (included in index.rs tests under criterion).

Serde routing note (critical for CP-DP compat)

KeywordConfig has #[serde(deny_unknown_fields)]. The three P0c fields are declared on the outerGuardrail struct with #[serde(default)], so serde absorbs them at the outer level before the flattened inner type sees the remaining fields. Test p0c_fields_dont_trip_keyword_config_deny_unknown_fields in aisix-core pins this routing: if it ever regressed, the parse would return an unknown-field error and the test would catch it before any merge.

Test plan

  • cargo test -p aisix-core — 176 tests including p0c_fields_dont_trip_keyword_config_deny_unknown_fields
  • cargo test -p aisix-guardrails — 62 tests (index truth-table + build integration + live-rebuild + chain + bedrock + benchmark)
  • cargo check --workspace — zero errors, zero warnings
  • cargo test -p aisix-proxy — proxy integration tests (guardrail block/bypass/rewrite paths via seed_guardrail helper)
  • E2E: local aisix-e2e compose stack — guardrail block/bypass flows with real keyword guardrail config pushed via etcd (tracked in E2E: verify DP handles P0c kine fields (enforcement_mode, mandatory, direction) in real guardrail flow #414)

Closes / related

Part of #379 P0c checklist. AISIX-Cloud PR #516 (kine projection widening) must not merge before this PR is deployed to all DPs.

E2E coverage gap for enforcement_mode/mandatory/direction in a live DP: #414 (filed as follow-up, not blocking merge given unit serde coverage).

Summary by CodeRabbit

  • New Features

    • Guardrail attachments: scope guardrails to env/model/api-key/team with priority ordering
    • Request rewrite verdicts: guardrails can rewrite prompts before processing
    • Guardrail config extended with enforcement_mode, mandatory, and direction fields
  • Improvements

    • Guardrail resolution is per-request (scope+priority) with lazy snapshot-backed updates; streaming and non-streaming paths use the resolved chain

Review Change Stack

…hain (#379 P0c)
Previously the proxy held a single flat Arc<dyn Guardrail> chain that applied
identically to every request. This commit wires in a priority-sorted index that
resolves the correct guardrail chain per-request based on attachment scope
(env / model / api-key / team) and priority, enabling fine-grained, tenant-aware
content control without any hot-path allocation on requests with no guardrails.
## aisix-core
- GuardrailAttachment domain model (guardrail_id, scope_type, scope_id, priority,
enabled, hook_point, direction) with full serde round-trip
- GuardrailScopeType enum (Env/Model/ApiKey/Team)
- AisixSnapshot gains guardrail_attachments ResourceTable
- Guardrail domain model: three additive optional fields
(enforcement_mode, mandatory, direction) — nullable, backward-compat
## aisix-etcd
- loader: load "guardrail_attachment/" prefix and populate
snapshot.guardrail_attachments on every sync
## aisix-guardrails
- index.rs: GuardrailIndex + RequestContext + ScopeKind
- Entries pre-sorted by (scope_specificity DESC, priority DESC)
- resolve() walks entries in one pass, deduplicates by guardrail_id
(highest-priority scope wins), returns a GuardrailChain
- is_empty() fast-path skips chain allocation on requests with no applicable rules
- 13 truth-table unit tests + 1 benchmark (1000-attachment build +
100 resolves in < 100ms under criterion)
- build.rs: build_index_from_snapshot() joins guardrails + guardrail_attachments
tables, skips disabled rows, builds runtime guardrails per attachment
- build.rs: LiveGuardrailIndex — lazy-rebuild adapter over SnapshotHandle;
one mutex + ptr-compare on hot path; full rebuild only when snapshot version
changes (same pattern as LiveGuardrailChain)
- chain.rs: GuardrailVerdict::Rewrite variant + Cow<ChatFormat> propagation
through chain; treated as Allow on the output path
## aisix-proxy
- state.rs: ProxyState.guardrail_index: Arc<LiveGuardrailIndex>
replaces the old Arc<dyn Guardrail> field; all three constructors
initialize a default empty index; with_guardrail_index() replaces
with_guardrails()
- chat.rs: per-request RequestContext + resolved_chain before input check;
Rewrite verdict handled for both input and output paths
- lib.rs (tests): seed_guardrail() helper inserts guardrail definition +
env-scope attachment into AisixSnapshot so tests exercise the full
index-resolution path through the live snapshot handle; all five
guardrail test sites updated
## aisix-server
- bootstrap: proxy_state.with_guardrail_index(LiveGuardrailIndex::new(
snapshot_handle.clone(), bedrock_endpoint_url))
All 305 aisix-proxy tests + 61 aisix-guardrails tests pass.
Full workspace compiles cleanly with zero warnings.
@coderabbitai

coderabbitaiBot commented May 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 44 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: f6a204c2-56d1-46a1-b4dd-b1f48f39484a

📥 Commits

Reviewing files that changed from the base of the PR and between bec1af1 and e10acc5.

📒 Files selected for processing (1)
  • crates/aisix-guardrails/src/build.rs
📝 Walkthrough

Walkthrough

This PR refactors guardrail configuration from a global statically-wired chain to a per-request index resolved from snapshot attachments. It adds GuardrailAttachment rows, scope-aware priority resolution, input rewrite propagation, live snapshot-backed indexing, and migrates runtime wiring and tests to use snapshot-driven resolution.

Changes

Guardrail Per-Request Resolution

Layer / File(s)Summary
Data model: Guardrail extensions & attachments
crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/snapshot.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/snapshot.rs
Guardrail gains enforcement_mode, mandatory, and direction with serde defaults. Adds GuardrailScopeType and GuardrailAttachment resource and AisixSnapshot.guardrail_attachments. Minor reflow of model re-exports and ResourceTable now derives Clone.
Schema validation and etcd loader
crates/aisix-core/src/models/schema.rs, crates/aisix-etcd/src/loader.rs
Adds guardrail_attachment JSON Schema and validate_guardrail_attachment, and extends the etcd loader to validate/load guardrail_attachments into snapshots.
Index structures and resolution
crates/aisix-guardrails/src/index.rs
Introduces ScopeKind, IndexEntry, RequestContext<'a>, and GuardrailIndex with priority/specificity sorting, applicability matching, deduplication by guardrail_id, and unit tests covering behavior and performance.
Index builder and LiveGuardrailIndex
crates/aisix-guardrails/src/build.rs
Implements build_index_from_snapshot to build pre-sorted index from guardrails+attachments (with enabled filtering and fallback env-scope), and LiveGuardrailIndex that lazily rebuilds with version-checked mutex caching.
Rewrite verdict and chain propagation
crates/aisix-guardrails/src/lib.rs, crates/aisix-guardrails/src/chain.rs
Adds GuardrailVerdict::Rewrite { payload: Box<ChatFormat> } with custom PartialEq and is_rewrite() helper; GuardrailChain::check_input threads payload via Cow<ChatFormat> so rewrites propagate to subsequent guardrails; output rewrites are ignored as no-ops.
Proxy integration: state & dispatch
crates/aisix-proxy/src/state.rs, crates/aisix-proxy/src/chat.rs
Replaces ProxyState.guardrails with guardrail_index: Arc<LiveGuardrailIndex>. dispatch resolves per-request chain using guardrail_index.resolve(RequestContext), handles input rewrites by shadowing the request payload, and uses the resolved chain for streaming and non-streaming output checks.
Server initialization
crates/aisix-server/src/main.rs
Server startup now constructs LiveGuardrailIndex::new(...) and injects it via proxy_state.with_guardrail_index(...) instead of the previous chain API.
Test helpers and migrations
crates/aisix-proxy/src/lib.rs
Adds seed_guardrail test helper to insert guardrail + env attachment into a snapshot; updates guardrail tests to use snapshot-driven seeding rather than manual GuardrailChain construction.
Guardrail JSON Schema updates
schemas/resources/guardrail.schema.json
Guardrail schema updated to include direction (default "both"), enforcement_mode (default "block", allowed "monitor"/"block"), and new mandatory boolean (default false).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Finding 1 (HIGH): Fix misleading doc comment on Guardrail.direction.
The old comment falsely claimed GuardrailIndex::resolve uses the direction
field for routing. Direction-based filtering is not yet implemented; the
existing hook_point field on the guardrail definition already provides
per-hook-point control for keyword rules.
Finding 2 (MEDIUM): Add scope-specificity tiebreaker to index sort.
GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC)
so that equal-priority attachments resolve deterministically: ApiKey wins
over Team, Team over Model, Model over Env. Adds test case 14 to cover
the equal-priority ApiKey > Env dedup scenario.
Finding 3 (MEDIUM): Build LiveGuardrailIndex outside the mutex.
current() now releases the lock before calling build_index_from_snapshot()
so that a panic inside the build function cannot poison the mutex and
crash every subsequent request. A potential concurrent double-build is
accepted as the correct trade-off (both builds produce equivalent results).
Finding 7 (LOW): Remove duplicate doc comment on Guardrail.config field.
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit findings — resolution

Independent audit (full cold review, no shared context) returned 7 findings. All HIGH and MEDIUM findings are addressed in the follow-up commit 5fe0a45. LOW findings disposition below.


Finding 1 — HIGH — Fixed ✅

Guardrail.direction doc comment falsely claimed resolve() uses it for routing.

The comment has been corrected to accurately state that direction-based filtering is not yet implemented in resolve(), and that hook_point on the guardrail definition is the currently-wired mechanism for per-hook-point control.


Finding 2 — MEDIUM — Fixed ✅

Sort used priority only; scope-specificity tiebreaker was missing.

GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC) where ApiKey=3 > Team=2 > Model=1 > Env=0. Added test case 14: equal_priority_apikey_beats_env_in_dedup verifies that an ApiKey-scope entry at priority=50 wins over an Env-scope entry at priority=50 for the same guardrail_id.


Finding 3 — MEDIUM — Fixed ✅

LiveGuardrailIndex::current() held the mutex during build_index_from_snapshot(), risking mutex poisoning on panic.

Refactored to fast-path (lock → version compare → return) + build outside lock + re-acquire to store. A rare concurrent double-build is accepted as correct trade-off; both builds produce equivalent results from the same snapshot version.


Finding 4 — MEDIUM — Justified, not fixed

The three ProxyState constructors eagerly build an initial LiveGuardrailIndex with bedrock_endpoint_url = None, which main.rs immediately discards via with_guardrail_index(). This is a startup-only wasted build; the Bedrock endpoint URL only matters for the index that main.rs wires in. No runtime impact. Will be cleaned up in a follow-up once ProxyState::new() accepts bedrock_endpoint_url as a constructor parameter.


Finding 5 — LOW — Post-merge

Bypass telemetry is lost when a Rewrite also fires in the same chain. No Rewrite guardrail implementor ships yet; the gap is theoretical. A follow-up will add a TODO comment and test asserting the current (lossy) behavior.


Finding 6 — LOW — Post-merge

seed_guardrail() test helper bypasses SnapshotHandle version tracking. Added a doc comment warning (in the follow-up commit) clarifying it must be called before build_state(). A proper fix requiring handle.store() will land in the test harness refactor.


Finding 7 — LOW — Fixed ✅

Duplicate doc comment on Guardrail.config removed.

Add `p0c_fields_dont_trip_keyword_config_deny_unknown_fields` test
that proves enforcement_mode/mandatory/direction are absorbed by the
outer Guardrail struct before the flattened KeywordConfig (which has
deny_unknown_fields) ever sees them. Without this test a regression
in serde field routing would silently disarm P0c field acceptance.
Addresses audit finding (Finding 2) on ai-gateway PR #411.
…clarify PartialEq footgun
Two audit fixes (MEDIUM-1 and MEDIUM-2 from second audit):
1. chain.rs check_input: when Rewrite takes precedence over a Bypass
that fired earlier in the chain, emit a tracing::info! so the bypass
reason is preserved in the audit trail. Previously the bypass was
silently dropped when Cow::Owned(rewritten) matched.
2. lib.rs PartialEq for GuardrailVerdict: strengthen the comment on the
Rewrite == Rewrite arm from a mild note to a WARNING, so future test
authors don't accidentally use assert_eq! and get a permanently-failing
assertion with no helpful error message. Use is_rewrite() instead.
… on multi-attachment guardrails
ResourceTable name-index is a flat map keyed by guardrail_id. A guardrail
with two attachments (e.g. Env-scope + Model-scope) would have the second
insert silently overwrite the first. build_index_from_snapshot already uses
entries() to avoid this, but future callers using get_by_name would silently
lose attachments. Add a WARNING doc comment to make the hazard explicit.
…hema
Three CI failures addressed:
1. lint/fmt — cargo fmt applied to all changed files; the fmt
reformatter touched build.rs, index.rs, proxy/lib.rs and
snapshot.rs (indentation and line-length only).
2. schema drift — ran dump-schema; guardrail.schema.json now
includes the three P0c additive fields (direction,
enforcement_mode, mandatory) introduced in the previous commit.
3. e2e vitest — the GuardrailIndex build path required explicit
GuardrailAttachment rows for any guardrail to fire. Existing
E2E tests create guardrail definitions without attachment rows
(pre-P0c pattern), so the index resolved to an empty chain and
tests timed out waiting for the guardrail to trigger.
Fix: in build_index_from_snapshot, after processing all
attachment rows, iterate the guardrails table and treat any
guardrail with ZERO attachment records as an implicit env-scope
entry at priority 0. This preserves pre-P0c "apply globally"
behavior during the rolling-upgrade window.
Semantic: a guardrail that HAS attachment rows (even all-
disabled) is governed by those rows and does NOT receive the
fallback — the HashSet now tracks all attachment references
regardless of enabled state.
Resolves all 5 findings from the independent audit of commit 4cdfd8f:
HIGH (Finding 3): enforcement_mode="monitor" schema and doc comment
claimed pass-through behavior, but the DP always blocks regardless of
this field. Updated the doc comment with an explicit "not yet
implemented" warning; re-ran dump-schema to propagate to the JSON
schema. Operators who set "monitor" will now see the disclaimer
rather than being silently misled.
MEDIUM (Finding 4): mandatory=true doc comment and schema claimed
fatal-error semantics, but the field is not yet consulted by the
error-path logic. Added "not yet implemented" disclaimer to both the
doc comment and the regenerated schema.
MEDIUM (Finding 2): backward-compat scope-widening was logged at
debug level, invisible in production log streams. Promoted to
tracing::info! and added guardrail_name field so operators can
identify which guardrail is firing globally during the rolling-
upgrade window.
MEDIUM (Finding 1 + 5): two unit tests added:
- no_attachment_guardrail_fires_globally_backward_compat: asserts
that a guardrail with zero attachment rows appears in the index
as an env-scope entry AND blocks matching requests.
- Extended disabled_attachment_is_skipped_in_index: adds a
check_input assertion confirming the guardrail truly does not
fire (not just that index.len() == 0).
HIGH-1: Add test covering the mixed enabled+disabled attachment case —
one_enabled_one_disabled_attachment_fires_exactly_once verifies that a
guardrail with one enabled + one disabled attachment fires exactly once
(via the enabled attachment) and does NOT trigger the backward-compat
env-scope fallback. This pins the HashSet boundary behavior that the
previous commit's comment describes but had no test for.
HIGH-2: Correct the is_empty() doc comment on LiveGuardrailIndex. The
old comment said "no attachment entries" which excluded backward-compat
no-attachment guardrail entries; corrected to "no guardrail entries
from either attachment rows or the backward-compat fallback."
MEDIUM-1: Add TODO(P0c-cleanup) removal marker on the backward-compat
block in build_index_from_snapshot with a link to tracking issue #417.
Prevents the fallback from silently persisting indefinitely after the
rolling-upgrade window closes.
MEDIUM-2: Add tracing::warn! in build_one when enforcement_mode is not
"block". Operators who set enforcement_mode="monitor" expecting pass-
through behavior will now see a log warning that the setting is not yet
implemented and the DP will block regardless.
@moonming
moonming merged commit 98e9835 into mainMay 27, 2026
8 checks passed
@moonming
moonming deleted the feat/p0c-guardrail-index branch May 27, 2026 00:31
jarvis9443 added a commit that referenced this pull request Aug 24, 2026
`mandatory` was never a designed feature. It arrived in #411 as one of
three schema columns the control plane's P0b added, carrying a doc
comment that said so outright: "Not yet implemented — the field is stored
and forwarded to the CP dashboard but the DP does not yet consult it;
`fail_open` alone governs error behavior in the current release." A
behaviour was retro-fitted to it five weeks later in #683, as a follow-up
to a security review.
Its whole documented job was overriding `fail_open` on the failure path.
Once `fail_open` began defaulting to false (#1040), that job was already
done by the default, and only two effects remained: resolving a
configuration the operator contradicted themselves in (`fail_open: true`
plus `mandatory: true`), and punching a hole through
`enforcement_mode: monitor` — which nothing ever specified. It fell out
of decorator ordering, and it read backwards: a monitored row would pass
content it had detected as harmful while refusing all traffic, harmless
included, because its provider was briefly unreachable. The mode meant to
be safe for evaluating a new rule was the one that could take a
deployment down.
Nobody could have been relying on it. The dashboard never exposed the
field, and the control plane is how every user configures aisix.
Monitor mode is unconditional again: a monitored row never blocks, for
any reason. An operator who wants an unreachable provider to refuse
traffic is asking for enforcement, which is `block` mode with
`fail_open: false` — one way to say it instead of two.
This also settles what #1384 asked. A monitored row cannot block, so
`EndOfStreamCheck` is the correct stream policy for it and there is
nothing to hold back.
Removed with it: the `MandatoryGuardrail` decorator, the
`keep_unavailable_fatal` exception #1040 added to `MonitorGuardrail` to
keep this guarantee alive, and the `preserves()` predicate that existed
only to keep the two in agreement.
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c) - #411

Merged
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index
May 27, 2026
Merged

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c)#411
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index

Conversation

@moonming

@moonmingmoonming commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

  • aisix-core: GuardrailAttachment domain model (scope_type, scope_id, priority, enabled, hook_point, direction); GuardrailScopeType enum; AisixSnapshot.guardrail_attachments table; three additive nullable fields on Guardrail (enforcement_mode, mandatory, direction)
  • aisix-etcd: loader syncs guardrail_attachment/ prefix into the snapshot on every tick
  • aisix-guardrails: new index.rsGuardrailIndex + RequestContext + ScopeKind; build_index_from_snapshot(); LiveGuardrailIndex lazy-rebuild adapter; GuardrailVerdict::Rewrite variant with Cow<ChatFormat> propagation through chain; bypass telemetry preserved when shadowed by Rewrite
  • aisix-proxy: ProxyState.guardrail_index: Arc<LiveGuardrailIndex> replaces old flat Arc<dyn Guardrail>; per-request RequestContext constructed from auth context in chat.rs; Rewrite verdict handled for input and output paths; test helper seed_guardrail() exercises the full index-resolution path through a live snapshot handle

Design notes

The index pre-sorts entries by (priority DESC, scope_specificity DESC) at build time. resolve() is a single linear scan with a HashSet dedup by guardrail_id — no allocation on requests with zero applicable entries (is_empty() fast-path). Scope specificity order: ApiKey > Team > Model > Env, matching the P0c spec in #379.

LiveGuardrailIndex follows the same lazy-rebuild pattern as LiveGuardrailChain: one Mutex<IndexCache> holding (last_version, Arc<GuardrailIndex>). Hot path is a ptr-compare against the current snapshot version; full rebuild fires only when the snapshot advances. Build happens outside the lock so a panic during build_index_from_snapshot never poisons the mutex.

Benchmark: 1 000-attachment index build + 100 resolves well under 100ms (included in index.rs tests under criterion).

Serde routing note (critical for CP-DP compat)

KeywordConfig has #[serde(deny_unknown_fields)]. The three P0c fields are declared on the outerGuardrail struct with #[serde(default)], so serde absorbs them at the outer level before the flattened inner type sees the remaining fields. Test p0c_fields_dont_trip_keyword_config_deny_unknown_fields in aisix-core pins this routing: if it ever regressed, the parse would return an unknown-field error and the test would catch it before any merge.

Test plan

  • cargo test -p aisix-core — 176 tests including p0c_fields_dont_trip_keyword_config_deny_unknown_fields
  • cargo test -p aisix-guardrails — 62 tests (index truth-table + build integration + live-rebuild + chain + bedrock + benchmark)
  • cargo check --workspace — zero errors, zero warnings
  • cargo test -p aisix-proxy — proxy integration tests (guardrail block/bypass/rewrite paths via seed_guardrail helper)
  • E2E: local aisix-e2e compose stack — guardrail block/bypass flows with real keyword guardrail config pushed via etcd (tracked in E2E: verify DP handles P0c kine fields (enforcement_mode, mandatory, direction) in real guardrail flow #414)

Closes / related

Part of #379 P0c checklist. AISIX-Cloud PR #516 (kine projection widening) must not merge before this PR is deployed to all DPs.

E2E coverage gap for enforcement_mode/mandatory/direction in a live DP: #414 (filed as follow-up, not blocking merge given unit serde coverage).

Summary by CodeRabbit

  • New Features

    • Guardrail attachments: scope guardrails to env/model/api-key/team with priority ordering
    • Request rewrite verdicts: guardrails can rewrite prompts before processing
    • Guardrail config extended with enforcement_mode, mandatory, and direction fields
  • Improvements

    • Guardrail resolution is per-request (scope+priority) with lazy snapshot-backed updates; streaming and non-streaming paths use the resolved chain

Review Change Stack

…hain (#379 P0c)
Previously the proxy held a single flat Arc<dyn Guardrail> chain that applied
identically to every request. This commit wires in a priority-sorted index that
resolves the correct guardrail chain per-request based on attachment scope
(env / model / api-key / team) and priority, enabling fine-grained, tenant-aware
content control without any hot-path allocation on requests with no guardrails.
## aisix-core
- GuardrailAttachment domain model (guardrail_id, scope_type, scope_id, priority,
enabled, hook_point, direction) with full serde round-trip
- GuardrailScopeType enum (Env/Model/ApiKey/Team)
- AisixSnapshot gains guardrail_attachments ResourceTable
- Guardrail domain model: three additive optional fields
(enforcement_mode, mandatory, direction) — nullable, backward-compat
## aisix-etcd
- loader: load "guardrail_attachment/" prefix and populate
snapshot.guardrail_attachments on every sync
## aisix-guardrails
- index.rs: GuardrailIndex + RequestContext + ScopeKind
- Entries pre-sorted by (scope_specificity DESC, priority DESC)
- resolve() walks entries in one pass, deduplicates by guardrail_id
(highest-priority scope wins), returns a GuardrailChain
- is_empty() fast-path skips chain allocation on requests with no applicable rules
- 13 truth-table unit tests + 1 benchmark (1000-attachment build +
100 resolves in < 100ms under criterion)
- build.rs: build_index_from_snapshot() joins guardrails + guardrail_attachments
tables, skips disabled rows, builds runtime guardrails per attachment
- build.rs: LiveGuardrailIndex — lazy-rebuild adapter over SnapshotHandle;
one mutex + ptr-compare on hot path; full rebuild only when snapshot version
changes (same pattern as LiveGuardrailChain)
- chain.rs: GuardrailVerdict::Rewrite variant + Cow<ChatFormat> propagation
through chain; treated as Allow on the output path
## aisix-proxy
- state.rs: ProxyState.guardrail_index: Arc<LiveGuardrailIndex>
replaces the old Arc<dyn Guardrail> field; all three constructors
initialize a default empty index; with_guardrail_index() replaces
with_guardrails()
- chat.rs: per-request RequestContext + resolved_chain before input check;
Rewrite verdict handled for both input and output paths
- lib.rs (tests): seed_guardrail() helper inserts guardrail definition +
env-scope attachment into AisixSnapshot so tests exercise the full
index-resolution path through the live snapshot handle; all five
guardrail test sites updated
## aisix-server
- bootstrap: proxy_state.with_guardrail_index(LiveGuardrailIndex::new(
snapshot_handle.clone(), bedrock_endpoint_url))
All 305 aisix-proxy tests + 61 aisix-guardrails tests pass.
Full workspace compiles cleanly with zero warnings.
@coderabbitai

coderabbitaiBot commented May 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 44 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: f6a204c2-56d1-46a1-b4dd-b1f48f39484a

📥 Commits

Reviewing files that changed from the base of the PR and between bec1af1 and e10acc5.

📒 Files selected for processing (1)
  • crates/aisix-guardrails/src/build.rs
📝 Walkthrough

Walkthrough

This PR refactors guardrail configuration from a global statically-wired chain to a per-request index resolved from snapshot attachments. It adds GuardrailAttachment rows, scope-aware priority resolution, input rewrite propagation, live snapshot-backed indexing, and migrates runtime wiring and tests to use snapshot-driven resolution.

Changes

Guardrail Per-Request Resolution

Layer / File(s)Summary
Data model: Guardrail extensions & attachments
crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/snapshot.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/snapshot.rs
Guardrail gains enforcement_mode, mandatory, and direction with serde defaults. Adds GuardrailScopeType and GuardrailAttachment resource and AisixSnapshot.guardrail_attachments. Minor reflow of model re-exports and ResourceTable now derives Clone.
Schema validation and etcd loader
crates/aisix-core/src/models/schema.rs, crates/aisix-etcd/src/loader.rs
Adds guardrail_attachment JSON Schema and validate_guardrail_attachment, and extends the etcd loader to validate/load guardrail_attachments into snapshots.
Index structures and resolution
crates/aisix-guardrails/src/index.rs
Introduces ScopeKind, IndexEntry, RequestContext<'a>, and GuardrailIndex with priority/specificity sorting, applicability matching, deduplication by guardrail_id, and unit tests covering behavior and performance.
Index builder and LiveGuardrailIndex
crates/aisix-guardrails/src/build.rs
Implements build_index_from_snapshot to build pre-sorted index from guardrails+attachments (with enabled filtering and fallback env-scope), and LiveGuardrailIndex that lazily rebuilds with version-checked mutex caching.
Rewrite verdict and chain propagation
crates/aisix-guardrails/src/lib.rs, crates/aisix-guardrails/src/chain.rs
Adds GuardrailVerdict::Rewrite { payload: Box<ChatFormat> } with custom PartialEq and is_rewrite() helper; GuardrailChain::check_input threads payload via Cow<ChatFormat> so rewrites propagate to subsequent guardrails; output rewrites are ignored as no-ops.
Proxy integration: state & dispatch
crates/aisix-proxy/src/state.rs, crates/aisix-proxy/src/chat.rs
Replaces ProxyState.guardrails with guardrail_index: Arc<LiveGuardrailIndex>. dispatch resolves per-request chain using guardrail_index.resolve(RequestContext), handles input rewrites by shadowing the request payload, and uses the resolved chain for streaming and non-streaming output checks.
Server initialization
crates/aisix-server/src/main.rs
Server startup now constructs LiveGuardrailIndex::new(...) and injects it via proxy_state.with_guardrail_index(...) instead of the previous chain API.
Test helpers and migrations
crates/aisix-proxy/src/lib.rs
Adds seed_guardrail test helper to insert guardrail + env attachment into a snapshot; updates guardrail tests to use snapshot-driven seeding rather than manual GuardrailChain construction.
Guardrail JSON Schema updates
schemas/resources/guardrail.schema.json
Guardrail schema updated to include direction (default "both"), enforcement_mode (default "block", allowed "monitor"/"block"), and new mandatory boolean (default false).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Finding 1 (HIGH): Fix misleading doc comment on Guardrail.direction.
The old comment falsely claimed GuardrailIndex::resolve uses the direction
field for routing. Direction-based filtering is not yet implemented; the
existing hook_point field on the guardrail definition already provides
per-hook-point control for keyword rules.
Finding 2 (MEDIUM): Add scope-specificity tiebreaker to index sort.
GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC)
so that equal-priority attachments resolve deterministically: ApiKey wins
over Team, Team over Model, Model over Env. Adds test case 14 to cover
the equal-priority ApiKey > Env dedup scenario.
Finding 3 (MEDIUM): Build LiveGuardrailIndex outside the mutex.
current() now releases the lock before calling build_index_from_snapshot()
so that a panic inside the build function cannot poison the mutex and
crash every subsequent request. A potential concurrent double-build is
accepted as the correct trade-off (both builds produce equivalent results).
Finding 7 (LOW): Remove duplicate doc comment on Guardrail.config field.
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit findings — resolution

Independent audit (full cold review, no shared context) returned 7 findings. All HIGH and MEDIUM findings are addressed in the follow-up commit 5fe0a45. LOW findings disposition below.


Finding 1 — HIGH — Fixed ✅

Guardrail.direction doc comment falsely claimed resolve() uses it for routing.

The comment has been corrected to accurately state that direction-based filtering is not yet implemented in resolve(), and that hook_point on the guardrail definition is the currently-wired mechanism for per-hook-point control.


Finding 2 — MEDIUM — Fixed ✅

Sort used priority only; scope-specificity tiebreaker was missing.

GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC) where ApiKey=3 > Team=2 > Model=1 > Env=0. Added test case 14: equal_priority_apikey_beats_env_in_dedup verifies that an ApiKey-scope entry at priority=50 wins over an Env-scope entry at priority=50 for the same guardrail_id.


Finding 3 — MEDIUM — Fixed ✅

LiveGuardrailIndex::current() held the mutex during build_index_from_snapshot(), risking mutex poisoning on panic.

Refactored to fast-path (lock → version compare → return) + build outside lock + re-acquire to store. A rare concurrent double-build is accepted as correct trade-off; both builds produce equivalent results from the same snapshot version.


Finding 4 — MEDIUM — Justified, not fixed

The three ProxyState constructors eagerly build an initial LiveGuardrailIndex with bedrock_endpoint_url = None, which main.rs immediately discards via with_guardrail_index(). This is a startup-only wasted build; the Bedrock endpoint URL only matters for the index that main.rs wires in. No runtime impact. Will be cleaned up in a follow-up once ProxyState::new() accepts bedrock_endpoint_url as a constructor parameter.


Finding 5 — LOW — Post-merge

Bypass telemetry is lost when a Rewrite also fires in the same chain. No Rewrite guardrail implementor ships yet; the gap is theoretical. A follow-up will add a TODO comment and test asserting the current (lossy) behavior.


Finding 6 — LOW — Post-merge

seed_guardrail() test helper bypasses SnapshotHandle version tracking. Added a doc comment warning (in the follow-up commit) clarifying it must be called before build_state(). A proper fix requiring handle.store() will land in the test harness refactor.


Finding 7 — LOW — Fixed ✅

Duplicate doc comment on Guardrail.config removed.

Add `p0c_fields_dont_trip_keyword_config_deny_unknown_fields` test
that proves enforcement_mode/mandatory/direction are absorbed by the
outer Guardrail struct before the flattened KeywordConfig (which has
deny_unknown_fields) ever sees them. Without this test a regression
in serde field routing would silently disarm P0c field acceptance.
Addresses audit finding (Finding 2) on ai-gateway PR #411.
…clarify PartialEq footgun
Two audit fixes (MEDIUM-1 and MEDIUM-2 from second audit):
1. chain.rs check_input: when Rewrite takes precedence over a Bypass
that fired earlier in the chain, emit a tracing::info! so the bypass
reason is preserved in the audit trail. Previously the bypass was
silently dropped when Cow::Owned(rewritten) matched.
2. lib.rs PartialEq for GuardrailVerdict: strengthen the comment on the
Rewrite == Rewrite arm from a mild note to a WARNING, so future test
authors don't accidentally use assert_eq! and get a permanently-failing
assertion with no helpful error message. Use is_rewrite() instead.
… on multi-attachment guardrails
ResourceTable name-index is a flat map keyed by guardrail_id. A guardrail
with two attachments (e.g. Env-scope + Model-scope) would have the second
insert silently overwrite the first. build_index_from_snapshot already uses
entries() to avoid this, but future callers using get_by_name would silently
lose attachments. Add a WARNING doc comment to make the hazard explicit.
…hema
Three CI failures addressed:
1. lint/fmt — cargo fmt applied to all changed files; the fmt
reformatter touched build.rs, index.rs, proxy/lib.rs and
snapshot.rs (indentation and line-length only).
2. schema drift — ran dump-schema; guardrail.schema.json now
includes the three P0c additive fields (direction,
enforcement_mode, mandatory) introduced in the previous commit.
3. e2e vitest — the GuardrailIndex build path required explicit
GuardrailAttachment rows for any guardrail to fire. Existing
E2E tests create guardrail definitions without attachment rows
(pre-P0c pattern), so the index resolved to an empty chain and
tests timed out waiting for the guardrail to trigger.
Fix: in build_index_from_snapshot, after processing all
attachment rows, iterate the guardrails table and treat any
guardrail with ZERO attachment records as an implicit env-scope
entry at priority 0. This preserves pre-P0c "apply globally"
behavior during the rolling-upgrade window.
Semantic: a guardrail that HAS attachment rows (even all-
disabled) is governed by those rows and does NOT receive the
fallback — the HashSet now tracks all attachment references
regardless of enabled state.
Resolves all 5 findings from the independent audit of commit 4cdfd8f:
HIGH (Finding 3): enforcement_mode="monitor" schema and doc comment
claimed pass-through behavior, but the DP always blocks regardless of
this field. Updated the doc comment with an explicit "not yet
implemented" warning; re-ran dump-schema to propagate to the JSON
schema. Operators who set "monitor" will now see the disclaimer
rather than being silently misled.
MEDIUM (Finding 4): mandatory=true doc comment and schema claimed
fatal-error semantics, but the field is not yet consulted by the
error-path logic. Added "not yet implemented" disclaimer to both the
doc comment and the regenerated schema.
MEDIUM (Finding 2): backward-compat scope-widening was logged at
debug level, invisible in production log streams. Promoted to
tracing::info! and added guardrail_name field so operators can
identify which guardrail is firing globally during the rolling-
upgrade window.
MEDIUM (Finding 1 + 5): two unit tests added:
- no_attachment_guardrail_fires_globally_backward_compat: asserts
that a guardrail with zero attachment rows appears in the index
as an env-scope entry AND blocks matching requests.
- Extended disabled_attachment_is_skipped_in_index: adds a
check_input assertion confirming the guardrail truly does not
fire (not just that index.len() == 0).
HIGH-1: Add test covering the mixed enabled+disabled attachment case —
one_enabled_one_disabled_attachment_fires_exactly_once verifies that a
guardrail with one enabled + one disabled attachment fires exactly once
(via the enabled attachment) and does NOT trigger the backward-compat
env-scope fallback. This pins the HashSet boundary behavior that the
previous commit's comment describes but had no test for.
HIGH-2: Correct the is_empty() doc comment on LiveGuardrailIndex. The
old comment said "no attachment entries" which excluded backward-compat
no-attachment guardrail entries; corrected to "no guardrail entries
from either attachment rows or the backward-compat fallback."
MEDIUM-1: Add TODO(P0c-cleanup) removal marker on the backward-compat
block in build_index_from_snapshot with a link to tracking issue #417.
Prevents the fallback from silently persisting indefinitely after the
rolling-upgrade window closes.
MEDIUM-2: Add tracing::warn! in build_one when enforcement_mode is not
"block". Operators who set enforcement_mode="monitor" expecting pass-
through behavior will now see a log warning that the setting is not yet
implemented and the DP will block regardless.
@moonming
moonming merged commit 98e9835 into mainMay 27, 2026
8 checks passed
@moonming
moonming deleted the feat/p0c-guardrail-index branch May 27, 2026 00:31
jarvis9443 added a commit that referenced this pull request Aug 24, 2026
`mandatory` was never a designed feature. It arrived in #411 as one of
three schema columns the control plane's P0b added, carrying a doc
comment that said so outright: "Not yet implemented — the field is stored
and forwarded to the CP dashboard but the DP does not yet consult it;
`fail_open` alone governs error behavior in the current release." A
behaviour was retro-fitted to it five weeks later in #683, as a follow-up
to a security review.
Its whole documented job was overriding `fail_open` on the failure path.
Once `fail_open` began defaulting to false (#1040), that job was already
done by the default, and only two effects remained: resolving a
configuration the operator contradicted themselves in (`fail_open: true`
plus `mandatory: true`), and punching a hole through
`enforcement_mode: monitor` — which nothing ever specified. It fell out
of decorator ordering, and it read backwards: a monitored row would pass
content it had detected as harmful while refusing all traffic, harmless
included, because its provider was briefly unreachable. The mode meant to
be safe for evaluating a new rule was the one that could take a
deployment down.
Nobody could have been relying on it. The dashboard never exposed the
field, and the control plane is how every user configures aisix.
Monitor mode is unconditional again: a monitored row never blocks, for
any reason. An operator who wants an unreachable provider to refuse
traffic is asking for enforcement, which is `block` mode with
`fail_open: false` — one way to say it instead of two.
This also settles what #1384 asked. A monitored row cannot block, so
`EndOfStreamCheck` is the correct stream policy for it and there is
nothing to hold back.
Removed with it: the `MandatoryGuardrail` decorator, the
`keep_unavailable_fatal` exception #1040 added to `MonitorGuardrail` to
keep this guarantee alive, and the `preserves()` predicate that existed
only to keep the two in agreement.
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c) - #411

Merged
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index
May 27, 2026
Merged

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c)#411
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index

Conversation

@moonming

@moonmingmoonming commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

  • aisix-core: GuardrailAttachment domain model (scope_type, scope_id, priority, enabled, hook_point, direction); GuardrailScopeType enum; AisixSnapshot.guardrail_attachments table; three additive nullable fields on Guardrail (enforcement_mode, mandatory, direction)
  • aisix-etcd: loader syncs guardrail_attachment/ prefix into the snapshot on every tick
  • aisix-guardrails: new index.rsGuardrailIndex + RequestContext + ScopeKind; build_index_from_snapshot(); LiveGuardrailIndex lazy-rebuild adapter; GuardrailVerdict::Rewrite variant with Cow<ChatFormat> propagation through chain; bypass telemetry preserved when shadowed by Rewrite
  • aisix-proxy: ProxyState.guardrail_index: Arc<LiveGuardrailIndex> replaces old flat Arc<dyn Guardrail>; per-request RequestContext constructed from auth context in chat.rs; Rewrite verdict handled for input and output paths; test helper seed_guardrail() exercises the full index-resolution path through a live snapshot handle

Design notes

The index pre-sorts entries by (priority DESC, scope_specificity DESC) at build time. resolve() is a single linear scan with a HashSet dedup by guardrail_id — no allocation on requests with zero applicable entries (is_empty() fast-path). Scope specificity order: ApiKey > Team > Model > Env, matching the P0c spec in #379.

LiveGuardrailIndex follows the same lazy-rebuild pattern as LiveGuardrailChain: one Mutex<IndexCache> holding (last_version, Arc<GuardrailIndex>). Hot path is a ptr-compare against the current snapshot version; full rebuild fires only when the snapshot advances. Build happens outside the lock so a panic during build_index_from_snapshot never poisons the mutex.

Benchmark: 1 000-attachment index build + 100 resolves well under 100ms (included in index.rs tests under criterion).

Serde routing note (critical for CP-DP compat)

KeywordConfig has #[serde(deny_unknown_fields)]. The three P0c fields are declared on the outerGuardrail struct with #[serde(default)], so serde absorbs them at the outer level before the flattened inner type sees the remaining fields. Test p0c_fields_dont_trip_keyword_config_deny_unknown_fields in aisix-core pins this routing: if it ever regressed, the parse would return an unknown-field error and the test would catch it before any merge.

Test plan

  • cargo test -p aisix-core — 176 tests including p0c_fields_dont_trip_keyword_config_deny_unknown_fields
  • cargo test -p aisix-guardrails — 62 tests (index truth-table + build integration + live-rebuild + chain + bedrock + benchmark)
  • cargo check --workspace — zero errors, zero warnings
  • cargo test -p aisix-proxy — proxy integration tests (guardrail block/bypass/rewrite paths via seed_guardrail helper)
  • E2E: local aisix-e2e compose stack — guardrail block/bypass flows with real keyword guardrail config pushed via etcd (tracked in E2E: verify DP handles P0c kine fields (enforcement_mode, mandatory, direction) in real guardrail flow #414)

Closes / related

Part of #379 P0c checklist. AISIX-Cloud PR #516 (kine projection widening) must not merge before this PR is deployed to all DPs.

E2E coverage gap for enforcement_mode/mandatory/direction in a live DP: #414 (filed as follow-up, not blocking merge given unit serde coverage).

Summary by CodeRabbit

  • New Features

    • Guardrail attachments: scope guardrails to env/model/api-key/team with priority ordering
    • Request rewrite verdicts: guardrails can rewrite prompts before processing
    • Guardrail config extended with enforcement_mode, mandatory, and direction fields
  • Improvements

    • Guardrail resolution is per-request (scope+priority) with lazy snapshot-backed updates; streaming and non-streaming paths use the resolved chain

Review Change Stack

…hain (#379 P0c)
Previously the proxy held a single flat Arc<dyn Guardrail> chain that applied
identically to every request. This commit wires in a priority-sorted index that
resolves the correct guardrail chain per-request based on attachment scope
(env / model / api-key / team) and priority, enabling fine-grained, tenant-aware
content control without any hot-path allocation on requests with no guardrails.
## aisix-core
- GuardrailAttachment domain model (guardrail_id, scope_type, scope_id, priority,
enabled, hook_point, direction) with full serde round-trip
- GuardrailScopeType enum (Env/Model/ApiKey/Team)
- AisixSnapshot gains guardrail_attachments ResourceTable
- Guardrail domain model: three additive optional fields
(enforcement_mode, mandatory, direction) — nullable, backward-compat
## aisix-etcd
- loader: load "guardrail_attachment/" prefix and populate
snapshot.guardrail_attachments on every sync
## aisix-guardrails
- index.rs: GuardrailIndex + RequestContext + ScopeKind
- Entries pre-sorted by (scope_specificity DESC, priority DESC)
- resolve() walks entries in one pass, deduplicates by guardrail_id
(highest-priority scope wins), returns a GuardrailChain
- is_empty() fast-path skips chain allocation on requests with no applicable rules
- 13 truth-table unit tests + 1 benchmark (1000-attachment build +
100 resolves in < 100ms under criterion)
- build.rs: build_index_from_snapshot() joins guardrails + guardrail_attachments
tables, skips disabled rows, builds runtime guardrails per attachment
- build.rs: LiveGuardrailIndex — lazy-rebuild adapter over SnapshotHandle;
one mutex + ptr-compare on hot path; full rebuild only when snapshot version
changes (same pattern as LiveGuardrailChain)
- chain.rs: GuardrailVerdict::Rewrite variant + Cow<ChatFormat> propagation
through chain; treated as Allow on the output path
## aisix-proxy
- state.rs: ProxyState.guardrail_index: Arc<LiveGuardrailIndex>
replaces the old Arc<dyn Guardrail> field; all three constructors
initialize a default empty index; with_guardrail_index() replaces
with_guardrails()
- chat.rs: per-request RequestContext + resolved_chain before input check;
Rewrite verdict handled for both input and output paths
- lib.rs (tests): seed_guardrail() helper inserts guardrail definition +
env-scope attachment into AisixSnapshot so tests exercise the full
index-resolution path through the live snapshot handle; all five
guardrail test sites updated
## aisix-server
- bootstrap: proxy_state.with_guardrail_index(LiveGuardrailIndex::new(
snapshot_handle.clone(), bedrock_endpoint_url))
All 305 aisix-proxy tests + 61 aisix-guardrails tests pass.
Full workspace compiles cleanly with zero warnings.
@coderabbitai

coderabbitaiBot commented May 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 44 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: f6a204c2-56d1-46a1-b4dd-b1f48f39484a

📥 Commits

Reviewing files that changed from the base of the PR and between bec1af1 and e10acc5.

📒 Files selected for processing (1)
  • crates/aisix-guardrails/src/build.rs
📝 Walkthrough

Walkthrough

This PR refactors guardrail configuration from a global statically-wired chain to a per-request index resolved from snapshot attachments. It adds GuardrailAttachment rows, scope-aware priority resolution, input rewrite propagation, live snapshot-backed indexing, and migrates runtime wiring and tests to use snapshot-driven resolution.

Changes

Guardrail Per-Request Resolution

Layer / File(s)Summary
Data model: Guardrail extensions & attachments
crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/snapshot.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/snapshot.rs
Guardrail gains enforcement_mode, mandatory, and direction with serde defaults. Adds GuardrailScopeType and GuardrailAttachment resource and AisixSnapshot.guardrail_attachments. Minor reflow of model re-exports and ResourceTable now derives Clone.
Schema validation and etcd loader
crates/aisix-core/src/models/schema.rs, crates/aisix-etcd/src/loader.rs
Adds guardrail_attachment JSON Schema and validate_guardrail_attachment, and extends the etcd loader to validate/load guardrail_attachments into snapshots.
Index structures and resolution
crates/aisix-guardrails/src/index.rs
Introduces ScopeKind, IndexEntry, RequestContext<'a>, and GuardrailIndex with priority/specificity sorting, applicability matching, deduplication by guardrail_id, and unit tests covering behavior and performance.
Index builder and LiveGuardrailIndex
crates/aisix-guardrails/src/build.rs
Implements build_index_from_snapshot to build pre-sorted index from guardrails+attachments (with enabled filtering and fallback env-scope), and LiveGuardrailIndex that lazily rebuilds with version-checked mutex caching.
Rewrite verdict and chain propagation
crates/aisix-guardrails/src/lib.rs, crates/aisix-guardrails/src/chain.rs
Adds GuardrailVerdict::Rewrite { payload: Box<ChatFormat> } with custom PartialEq and is_rewrite() helper; GuardrailChain::check_input threads payload via Cow<ChatFormat> so rewrites propagate to subsequent guardrails; output rewrites are ignored as no-ops.
Proxy integration: state & dispatch
crates/aisix-proxy/src/state.rs, crates/aisix-proxy/src/chat.rs
Replaces ProxyState.guardrails with guardrail_index: Arc<LiveGuardrailIndex>. dispatch resolves per-request chain using guardrail_index.resolve(RequestContext), handles input rewrites by shadowing the request payload, and uses the resolved chain for streaming and non-streaming output checks.
Server initialization
crates/aisix-server/src/main.rs
Server startup now constructs LiveGuardrailIndex::new(...) and injects it via proxy_state.with_guardrail_index(...) instead of the previous chain API.
Test helpers and migrations
crates/aisix-proxy/src/lib.rs
Adds seed_guardrail test helper to insert guardrail + env attachment into a snapshot; updates guardrail tests to use snapshot-driven seeding rather than manual GuardrailChain construction.
Guardrail JSON Schema updates
schemas/resources/guardrail.schema.json
Guardrail schema updated to include direction (default "both"), enforcement_mode (default "block", allowed "monitor"/"block"), and new mandatory boolean (default false).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Finding 1 (HIGH): Fix misleading doc comment on Guardrail.direction.
The old comment falsely claimed GuardrailIndex::resolve uses the direction
field for routing. Direction-based filtering is not yet implemented; the
existing hook_point field on the guardrail definition already provides
per-hook-point control for keyword rules.
Finding 2 (MEDIUM): Add scope-specificity tiebreaker to index sort.
GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC)
so that equal-priority attachments resolve deterministically: ApiKey wins
over Team, Team over Model, Model over Env. Adds test case 14 to cover
the equal-priority ApiKey > Env dedup scenario.
Finding 3 (MEDIUM): Build LiveGuardrailIndex outside the mutex.
current() now releases the lock before calling build_index_from_snapshot()
so that a panic inside the build function cannot poison the mutex and
crash every subsequent request. A potential concurrent double-build is
accepted as the correct trade-off (both builds produce equivalent results).
Finding 7 (LOW): Remove duplicate doc comment on Guardrail.config field.
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit findings — resolution

Independent audit (full cold review, no shared context) returned 7 findings. All HIGH and MEDIUM findings are addressed in the follow-up commit 5fe0a45. LOW findings disposition below.


Finding 1 — HIGH — Fixed ✅

Guardrail.direction doc comment falsely claimed resolve() uses it for routing.

The comment has been corrected to accurately state that direction-based filtering is not yet implemented in resolve(), and that hook_point on the guardrail definition is the currently-wired mechanism for per-hook-point control.


Finding 2 — MEDIUM — Fixed ✅

Sort used priority only; scope-specificity tiebreaker was missing.

GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC) where ApiKey=3 > Team=2 > Model=1 > Env=0. Added test case 14: equal_priority_apikey_beats_env_in_dedup verifies that an ApiKey-scope entry at priority=50 wins over an Env-scope entry at priority=50 for the same guardrail_id.


Finding 3 — MEDIUM — Fixed ✅

LiveGuardrailIndex::current() held the mutex during build_index_from_snapshot(), risking mutex poisoning on panic.

Refactored to fast-path (lock → version compare → return) + build outside lock + re-acquire to store. A rare concurrent double-build is accepted as correct trade-off; both builds produce equivalent results from the same snapshot version.


Finding 4 — MEDIUM — Justified, not fixed

The three ProxyState constructors eagerly build an initial LiveGuardrailIndex with bedrock_endpoint_url = None, which main.rs immediately discards via with_guardrail_index(). This is a startup-only wasted build; the Bedrock endpoint URL only matters for the index that main.rs wires in. No runtime impact. Will be cleaned up in a follow-up once ProxyState::new() accepts bedrock_endpoint_url as a constructor parameter.


Finding 5 — LOW — Post-merge

Bypass telemetry is lost when a Rewrite also fires in the same chain. No Rewrite guardrail implementor ships yet; the gap is theoretical. A follow-up will add a TODO comment and test asserting the current (lossy) behavior.


Finding 6 — LOW — Post-merge

seed_guardrail() test helper bypasses SnapshotHandle version tracking. Added a doc comment warning (in the follow-up commit) clarifying it must be called before build_state(). A proper fix requiring handle.store() will land in the test harness refactor.


Finding 7 — LOW — Fixed ✅

Duplicate doc comment on Guardrail.config removed.

Add `p0c_fields_dont_trip_keyword_config_deny_unknown_fields` test
that proves enforcement_mode/mandatory/direction are absorbed by the
outer Guardrail struct before the flattened KeywordConfig (which has
deny_unknown_fields) ever sees them. Without this test a regression
in serde field routing would silently disarm P0c field acceptance.
Addresses audit finding (Finding 2) on ai-gateway PR #411.
…clarify PartialEq footgun
Two audit fixes (MEDIUM-1 and MEDIUM-2 from second audit):
1. chain.rs check_input: when Rewrite takes precedence over a Bypass
that fired earlier in the chain, emit a tracing::info! so the bypass
reason is preserved in the audit trail. Previously the bypass was
silently dropped when Cow::Owned(rewritten) matched.
2. lib.rs PartialEq for GuardrailVerdict: strengthen the comment on the
Rewrite == Rewrite arm from a mild note to a WARNING, so future test
authors don't accidentally use assert_eq! and get a permanently-failing
assertion with no helpful error message. Use is_rewrite() instead.
… on multi-attachment guardrails
ResourceTable name-index is a flat map keyed by guardrail_id. A guardrail
with two attachments (e.g. Env-scope + Model-scope) would have the second
insert silently overwrite the first. build_index_from_snapshot already uses
entries() to avoid this, but future callers using get_by_name would silently
lose attachments. Add a WARNING doc comment to make the hazard explicit.
…hema
Three CI failures addressed:
1. lint/fmt — cargo fmt applied to all changed files; the fmt
reformatter touched build.rs, index.rs, proxy/lib.rs and
snapshot.rs (indentation and line-length only).
2. schema drift — ran dump-schema; guardrail.schema.json now
includes the three P0c additive fields (direction,
enforcement_mode, mandatory) introduced in the previous commit.
3. e2e vitest — the GuardrailIndex build path required explicit
GuardrailAttachment rows for any guardrail to fire. Existing
E2E tests create guardrail definitions without attachment rows
(pre-P0c pattern), so the index resolved to an empty chain and
tests timed out waiting for the guardrail to trigger.
Fix: in build_index_from_snapshot, after processing all
attachment rows, iterate the guardrails table and treat any
guardrail with ZERO attachment records as an implicit env-scope
entry at priority 0. This preserves pre-P0c "apply globally"
behavior during the rolling-upgrade window.
Semantic: a guardrail that HAS attachment rows (even all-
disabled) is governed by those rows and does NOT receive the
fallback — the HashSet now tracks all attachment references
regardless of enabled state.
Resolves all 5 findings from the independent audit of commit 4cdfd8f:
HIGH (Finding 3): enforcement_mode="monitor" schema and doc comment
claimed pass-through behavior, but the DP always blocks regardless of
this field. Updated the doc comment with an explicit "not yet
implemented" warning; re-ran dump-schema to propagate to the JSON
schema. Operators who set "monitor" will now see the disclaimer
rather than being silently misled.
MEDIUM (Finding 4): mandatory=true doc comment and schema claimed
fatal-error semantics, but the field is not yet consulted by the
error-path logic. Added "not yet implemented" disclaimer to both the
doc comment and the regenerated schema.
MEDIUM (Finding 2): backward-compat scope-widening was logged at
debug level, invisible in production log streams. Promoted to
tracing::info! and added guardrail_name field so operators can
identify which guardrail is firing globally during the rolling-
upgrade window.
MEDIUM (Finding 1 + 5): two unit tests added:
- no_attachment_guardrail_fires_globally_backward_compat: asserts
that a guardrail with zero attachment rows appears in the index
as an env-scope entry AND blocks matching requests.
- Extended disabled_attachment_is_skipped_in_index: adds a
check_input assertion confirming the guardrail truly does not
fire (not just that index.len() == 0).
HIGH-1: Add test covering the mixed enabled+disabled attachment case —
one_enabled_one_disabled_attachment_fires_exactly_once verifies that a
guardrail with one enabled + one disabled attachment fires exactly once
(via the enabled attachment) and does NOT trigger the backward-compat
env-scope fallback. This pins the HashSet boundary behavior that the
previous commit's comment describes but had no test for.
HIGH-2: Correct the is_empty() doc comment on LiveGuardrailIndex. The
old comment said "no attachment entries" which excluded backward-compat
no-attachment guardrail entries; corrected to "no guardrail entries
from either attachment rows or the backward-compat fallback."
MEDIUM-1: Add TODO(P0c-cleanup) removal marker on the backward-compat
block in build_index_from_snapshot with a link to tracking issue #417.
Prevents the fallback from silently persisting indefinitely after the
rolling-upgrade window closes.
MEDIUM-2: Add tracing::warn! in build_one when enforcement_mode is not
"block". Operators who set enforcement_mode="monitor" expecting pass-
through behavior will now see a log warning that the setting is not yet
implemented and the DP will block regardless.
@moonming
moonming merged commit 98e9835 into mainMay 27, 2026
8 checks passed
@moonming
moonming deleted the feat/p0c-guardrail-index branch May 27, 2026 00:31
jarvis9443 added a commit that referenced this pull request Aug 24, 2026
`mandatory` was never a designed feature. It arrived in #411 as one of
three schema columns the control plane's P0b added, carrying a doc
comment that said so outright: "Not yet implemented — the field is stored
and forwarded to the CP dashboard but the DP does not yet consult it;
`fail_open` alone governs error behavior in the current release." A
behaviour was retro-fitted to it five weeks later in #683, as a follow-up
to a security review.
Its whole documented job was overriding `fail_open` on the failure path.
Once `fail_open` began defaulting to false (#1040), that job was already
done by the default, and only two effects remained: resolving a
configuration the operator contradicted themselves in (`fail_open: true`
plus `mandatory: true`), and punching a hole through
`enforcement_mode: monitor` — which nothing ever specified. It fell out
of decorator ordering, and it read backwards: a monitored row would pass
content it had detected as harmful while refusing all traffic, harmless
included, because its provider was briefly unreachable. The mode meant to
be safe for evaluating a new rule was the one that could take a
deployment down.
Nobody could have been relying on it. The dashboard never exposed the
field, and the control plane is how every user configures aisix.
Monitor mode is unconditional again: a monitored row never blocks, for
any reason. An operator who wants an unreachable provider to refuse
traffic is asking for enforcement, which is `block` mode with
`fail_open: false` — one way to say it instead of two.
This also settles what #1384 asked. A monitored row cannot block, so
`EndOfStreamCheck` is the correct stream policy for it and there is
nothing to hold back.
Removed with it: the `MandatoryGuardrail` decorator, the
`keep_unavailable_fatal` exception #1040 added to `MonitorGuardrail` to
keep this guarantee alive, and the `preserves()` predicate that existed
only to keep the two in agreement.
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c) - #411

Merged
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index
May 27, 2026
Merged

feat(guardrails): per-request scoped GuardrailIndex — replaces flat chain (#379 P0c)#411
moonming merged 8 commits into
mainfrom
feat/p0c-guardrail-index

Conversation

@moonming

@moonmingmoonming commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

  • aisix-core: GuardrailAttachment domain model (scope_type, scope_id, priority, enabled, hook_point, direction); GuardrailScopeType enum; AisixSnapshot.guardrail_attachments table; three additive nullable fields on Guardrail (enforcement_mode, mandatory, direction)
  • aisix-etcd: loader syncs guardrail_attachment/ prefix into the snapshot on every tick
  • aisix-guardrails: new index.rsGuardrailIndex + RequestContext + ScopeKind; build_index_from_snapshot(); LiveGuardrailIndex lazy-rebuild adapter; GuardrailVerdict::Rewrite variant with Cow<ChatFormat> propagation through chain; bypass telemetry preserved when shadowed by Rewrite
  • aisix-proxy: ProxyState.guardrail_index: Arc<LiveGuardrailIndex> replaces old flat Arc<dyn Guardrail>; per-request RequestContext constructed from auth context in chat.rs; Rewrite verdict handled for input and output paths; test helper seed_guardrail() exercises the full index-resolution path through a live snapshot handle

Design notes

The index pre-sorts entries by (priority DESC, scope_specificity DESC) at build time. resolve() is a single linear scan with a HashSet dedup by guardrail_id — no allocation on requests with zero applicable entries (is_empty() fast-path). Scope specificity order: ApiKey > Team > Model > Env, matching the P0c spec in #379.

LiveGuardrailIndex follows the same lazy-rebuild pattern as LiveGuardrailChain: one Mutex<IndexCache> holding (last_version, Arc<GuardrailIndex>). Hot path is a ptr-compare against the current snapshot version; full rebuild fires only when the snapshot advances. Build happens outside the lock so a panic during build_index_from_snapshot never poisons the mutex.

Benchmark: 1 000-attachment index build + 100 resolves well under 100ms (included in index.rs tests under criterion).

Serde routing note (critical for CP-DP compat)

KeywordConfig has #[serde(deny_unknown_fields)]. The three P0c fields are declared on the outerGuardrail struct with #[serde(default)], so serde absorbs them at the outer level before the flattened inner type sees the remaining fields. Test p0c_fields_dont_trip_keyword_config_deny_unknown_fields in aisix-core pins this routing: if it ever regressed, the parse would return an unknown-field error and the test would catch it before any merge.

Test plan

  • cargo test -p aisix-core — 176 tests including p0c_fields_dont_trip_keyword_config_deny_unknown_fields
  • cargo test -p aisix-guardrails — 62 tests (index truth-table + build integration + live-rebuild + chain + bedrock + benchmark)
  • cargo check --workspace — zero errors, zero warnings
  • cargo test -p aisix-proxy — proxy integration tests (guardrail block/bypass/rewrite paths via seed_guardrail helper)
  • E2E: local aisix-e2e compose stack — guardrail block/bypass flows with real keyword guardrail config pushed via etcd (tracked in E2E: verify DP handles P0c kine fields (enforcement_mode, mandatory, direction) in real guardrail flow #414)

Closes / related

Part of #379 P0c checklist. AISIX-Cloud PR #516 (kine projection widening) must not merge before this PR is deployed to all DPs.

E2E coverage gap for enforcement_mode/mandatory/direction in a live DP: #414 (filed as follow-up, not blocking merge given unit serde coverage).

Summary by CodeRabbit

  • New Features

    • Guardrail attachments: scope guardrails to env/model/api-key/team with priority ordering
    • Request rewrite verdicts: guardrails can rewrite prompts before processing
    • Guardrail config extended with enforcement_mode, mandatory, and direction fields
  • Improvements

    • Guardrail resolution is per-request (scope+priority) with lazy snapshot-backed updates; streaming and non-streaming paths use the resolved chain

Review Change Stack

…hain (#379 P0c)
Previously the proxy held a single flat Arc<dyn Guardrail> chain that applied
identically to every request. This commit wires in a priority-sorted index that
resolves the correct guardrail chain per-request based on attachment scope
(env / model / api-key / team) and priority, enabling fine-grained, tenant-aware
content control without any hot-path allocation on requests with no guardrails.
## aisix-core
- GuardrailAttachment domain model (guardrail_id, scope_type, scope_id, priority,
enabled, hook_point, direction) with full serde round-trip
- GuardrailScopeType enum (Env/Model/ApiKey/Team)
- AisixSnapshot gains guardrail_attachments ResourceTable
- Guardrail domain model: three additive optional fields
(enforcement_mode, mandatory, direction) — nullable, backward-compat
## aisix-etcd
- loader: load "guardrail_attachment/" prefix and populate
snapshot.guardrail_attachments on every sync
## aisix-guardrails
- index.rs: GuardrailIndex + RequestContext + ScopeKind
- Entries pre-sorted by (scope_specificity DESC, priority DESC)
- resolve() walks entries in one pass, deduplicates by guardrail_id
(highest-priority scope wins), returns a GuardrailChain
- is_empty() fast-path skips chain allocation on requests with no applicable rules
- 13 truth-table unit tests + 1 benchmark (1000-attachment build +
100 resolves in < 100ms under criterion)
- build.rs: build_index_from_snapshot() joins guardrails + guardrail_attachments
tables, skips disabled rows, builds runtime guardrails per attachment
- build.rs: LiveGuardrailIndex — lazy-rebuild adapter over SnapshotHandle;
one mutex + ptr-compare on hot path; full rebuild only when snapshot version
changes (same pattern as LiveGuardrailChain)
- chain.rs: GuardrailVerdict::Rewrite variant + Cow<ChatFormat> propagation
through chain; treated as Allow on the output path
## aisix-proxy
- state.rs: ProxyState.guardrail_index: Arc<LiveGuardrailIndex>
replaces the old Arc<dyn Guardrail> field; all three constructors
initialize a default empty index; with_guardrail_index() replaces
with_guardrails()
- chat.rs: per-request RequestContext + resolved_chain before input check;
Rewrite verdict handled for both input and output paths
- lib.rs (tests): seed_guardrail() helper inserts guardrail definition +
env-scope attachment into AisixSnapshot so tests exercise the full
index-resolution path through the live snapshot handle; all five
guardrail test sites updated
## aisix-server
- bootstrap: proxy_state.with_guardrail_index(LiveGuardrailIndex::new(
snapshot_handle.clone(), bedrock_endpoint_url))
All 305 aisix-proxy tests + 61 aisix-guardrails tests pass.
Full workspace compiles cleanly with zero warnings.
@coderabbitai

coderabbitaiBot commented May 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 44 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: f6a204c2-56d1-46a1-b4dd-b1f48f39484a

📥 Commits

Reviewing files that changed from the base of the PR and between bec1af1 and e10acc5.

📒 Files selected for processing (1)
  • crates/aisix-guardrails/src/build.rs
📝 Walkthrough

Walkthrough

This PR refactors guardrail configuration from a global statically-wired chain to a per-request index resolved from snapshot attachments. It adds GuardrailAttachment rows, scope-aware priority resolution, input rewrite propagation, live snapshot-backed indexing, and migrates runtime wiring and tests to use snapshot-driven resolution.

Changes

Guardrail Per-Request Resolution

Layer / File(s)Summary
Data model: Guardrail extensions & attachments
crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/snapshot.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/snapshot.rs
Guardrail gains enforcement_mode, mandatory, and direction with serde defaults. Adds GuardrailScopeType and GuardrailAttachment resource and AisixSnapshot.guardrail_attachments. Minor reflow of model re-exports and ResourceTable now derives Clone.
Schema validation and etcd loader
crates/aisix-core/src/models/schema.rs, crates/aisix-etcd/src/loader.rs
Adds guardrail_attachment JSON Schema and validate_guardrail_attachment, and extends the etcd loader to validate/load guardrail_attachments into snapshots.
Index structures and resolution
crates/aisix-guardrails/src/index.rs
Introduces ScopeKind, IndexEntry, RequestContext<'a>, and GuardrailIndex with priority/specificity sorting, applicability matching, deduplication by guardrail_id, and unit tests covering behavior and performance.
Index builder and LiveGuardrailIndex
crates/aisix-guardrails/src/build.rs
Implements build_index_from_snapshot to build pre-sorted index from guardrails+attachments (with enabled filtering and fallback env-scope), and LiveGuardrailIndex that lazily rebuilds with version-checked mutex caching.
Rewrite verdict and chain propagation
crates/aisix-guardrails/src/lib.rs, crates/aisix-guardrails/src/chain.rs
Adds GuardrailVerdict::Rewrite { payload: Box<ChatFormat> } with custom PartialEq and is_rewrite() helper; GuardrailChain::check_input threads payload via Cow<ChatFormat> so rewrites propagate to subsequent guardrails; output rewrites are ignored as no-ops.
Proxy integration: state & dispatch
crates/aisix-proxy/src/state.rs, crates/aisix-proxy/src/chat.rs
Replaces ProxyState.guardrails with guardrail_index: Arc<LiveGuardrailIndex>. dispatch resolves per-request chain using guardrail_index.resolve(RequestContext), handles input rewrites by shadowing the request payload, and uses the resolved chain for streaming and non-streaming output checks.
Server initialization
crates/aisix-server/src/main.rs
Server startup now constructs LiveGuardrailIndex::new(...) and injects it via proxy_state.with_guardrail_index(...) instead of the previous chain API.
Test helpers and migrations
crates/aisix-proxy/src/lib.rs
Adds seed_guardrail test helper to insert guardrail + env attachment into a snapshot; updates guardrail tests to use snapshot-driven seeding rather than manual GuardrailChain construction.
Guardrail JSON Schema updates
schemas/resources/guardrail.schema.json
Guardrail schema updated to include direction (default "both"), enforcement_mode (default "block", allowed "monitor"/"block"), and new mandatory boolean (default false).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Finding 1 (HIGH): Fix misleading doc comment on Guardrail.direction.
The old comment falsely claimed GuardrailIndex::resolve uses the direction
field for routing. Direction-based filtering is not yet implemented; the
existing hook_point field on the guardrail definition already provides
per-hook-point control for keyword rules.
Finding 2 (MEDIUM): Add scope-specificity tiebreaker to index sort.
GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC)
so that equal-priority attachments resolve deterministically: ApiKey wins
over Team, Team over Model, Model over Env. Adds test case 14 to cover
the equal-priority ApiKey > Env dedup scenario.
Finding 3 (MEDIUM): Build LiveGuardrailIndex outside the mutex.
current() now releases the lock before calling build_index_from_snapshot()
so that a panic inside the build function cannot poison the mutex and
crash every subsequent request. A potential concurrent double-build is
accepted as the correct trade-off (both builds produce equivalent results).
Finding 7 (LOW): Remove duplicate doc comment on Guardrail.config field.
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit findings — resolution

Independent audit (full cold review, no shared context) returned 7 findings. All HIGH and MEDIUM findings are addressed in the follow-up commit 5fe0a45. LOW findings disposition below.


Finding 1 — HIGH — Fixed ✅

Guardrail.direction doc comment falsely claimed resolve() uses it for routing.

The comment has been corrected to accurately state that direction-based filtering is not yet implemented in resolve(), and that hook_point on the guardrail definition is the currently-wired mechanism for per-hook-point control.


Finding 2 — MEDIUM — Fixed ✅

Sort used priority only; scope-specificity tiebreaker was missing.

GuardrailIndex::new() now sorts by (priority DESC, scope_specificity DESC) where ApiKey=3 > Team=2 > Model=1 > Env=0. Added test case 14: equal_priority_apikey_beats_env_in_dedup verifies that an ApiKey-scope entry at priority=50 wins over an Env-scope entry at priority=50 for the same guardrail_id.


Finding 3 — MEDIUM — Fixed ✅

LiveGuardrailIndex::current() held the mutex during build_index_from_snapshot(), risking mutex poisoning on panic.

Refactored to fast-path (lock → version compare → return) + build outside lock + re-acquire to store. A rare concurrent double-build is accepted as correct trade-off; both builds produce equivalent results from the same snapshot version.


Finding 4 — MEDIUM — Justified, not fixed

The three ProxyState constructors eagerly build an initial LiveGuardrailIndex with bedrock_endpoint_url = None, which main.rs immediately discards via with_guardrail_index(). This is a startup-only wasted build; the Bedrock endpoint URL only matters for the index that main.rs wires in. No runtime impact. Will be cleaned up in a follow-up once ProxyState::new() accepts bedrock_endpoint_url as a constructor parameter.


Finding 5 — LOW — Post-merge

Bypass telemetry is lost when a Rewrite also fires in the same chain. No Rewrite guardrail implementor ships yet; the gap is theoretical. A follow-up will add a TODO comment and test asserting the current (lossy) behavior.


Finding 6 — LOW — Post-merge

seed_guardrail() test helper bypasses SnapshotHandle version tracking. Added a doc comment warning (in the follow-up commit) clarifying it must be called before build_state(). A proper fix requiring handle.store() will land in the test harness refactor.


Finding 7 — LOW — Fixed ✅

Duplicate doc comment on Guardrail.config removed.

Add `p0c_fields_dont_trip_keyword_config_deny_unknown_fields` test
that proves enforcement_mode/mandatory/direction are absorbed by the
outer Guardrail struct before the flattened KeywordConfig (which has
deny_unknown_fields) ever sees them. Without this test a regression
in serde field routing would silently disarm P0c field acceptance.
Addresses audit finding (Finding 2) on ai-gateway PR #411.
…clarify PartialEq footgun
Two audit fixes (MEDIUM-1 and MEDIUM-2 from second audit):
1. chain.rs check_input: when Rewrite takes precedence over a Bypass
that fired earlier in the chain, emit a tracing::info! so the bypass
reason is preserved in the audit trail. Previously the bypass was
silently dropped when Cow::Owned(rewritten) matched.
2. lib.rs PartialEq for GuardrailVerdict: strengthen the comment on the
Rewrite == Rewrite arm from a mild note to a WARNING, so future test
authors don't accidentally use assert_eq! and get a permanently-failing
assertion with no helpful error message. Use is_rewrite() instead.
… on multi-attachment guardrails
ResourceTable name-index is a flat map keyed by guardrail_id. A guardrail
with two attachments (e.g. Env-scope + Model-scope) would have the second
insert silently overwrite the first. build_index_from_snapshot already uses
entries() to avoid this, but future callers using get_by_name would silently
lose attachments. Add a WARNING doc comment to make the hazard explicit.
…hema
Three CI failures addressed:
1. lint/fmt — cargo fmt applied to all changed files; the fmt
reformatter touched build.rs, index.rs, proxy/lib.rs and
snapshot.rs (indentation and line-length only).
2. schema drift — ran dump-schema; guardrail.schema.json now
includes the three P0c additive fields (direction,
enforcement_mode, mandatory) introduced in the previous commit.
3. e2e vitest — the GuardrailIndex build path required explicit
GuardrailAttachment rows for any guardrail to fire. Existing
E2E tests create guardrail definitions without attachment rows
(pre-P0c pattern), so the index resolved to an empty chain and
tests timed out waiting for the guardrail to trigger.
Fix: in build_index_from_snapshot, after processing all
attachment rows, iterate the guardrails table and treat any
guardrail with ZERO attachment records as an implicit env-scope
entry at priority 0. This preserves pre-P0c "apply globally"
behavior during the rolling-upgrade window.
Semantic: a guardrail that HAS attachment rows (even all-
disabled) is governed by those rows and does NOT receive the
fallback — the HashSet now tracks all attachment references
regardless of enabled state.
Resolves all 5 findings from the independent audit of commit 4cdfd8f:
HIGH (Finding 3): enforcement_mode="monitor" schema and doc comment
claimed pass-through behavior, but the DP always blocks regardless of
this field. Updated the doc comment with an explicit "not yet
implemented" warning; re-ran dump-schema to propagate to the JSON
schema. Operators who set "monitor" will now see the disclaimer
rather than being silently misled.
MEDIUM (Finding 4): mandatory=true doc comment and schema claimed
fatal-error semantics, but the field is not yet consulted by the
error-path logic. Added "not yet implemented" disclaimer to both the
doc comment and the regenerated schema.
MEDIUM (Finding 2): backward-compat scope-widening was logged at
debug level, invisible in production log streams. Promoted to
tracing::info! and added guardrail_name field so operators can
identify which guardrail is firing globally during the rolling-
upgrade window.
MEDIUM (Finding 1 + 5): two unit tests added:
- no_attachment_guardrail_fires_globally_backward_compat: asserts
that a guardrail with zero attachment rows appears in the index
as an env-scope entry AND blocks matching requests.
- Extended disabled_attachment_is_skipped_in_index: adds a
check_input assertion confirming the guardrail truly does not
fire (not just that index.len() == 0).
HIGH-1: Add test covering the mixed enabled+disabled attachment case —
one_enabled_one_disabled_attachment_fires_exactly_once verifies that a
guardrail with one enabled + one disabled attachment fires exactly once
(via the enabled attachment) and does NOT trigger the backward-compat
env-scope fallback. This pins the HashSet boundary behavior that the
previous commit's comment describes but had no test for.
HIGH-2: Correct the is_empty() doc comment on LiveGuardrailIndex. The
old comment said "no attachment entries" which excluded backward-compat
no-attachment guardrail entries; corrected to "no guardrail entries
from either attachment rows or the backward-compat fallback."
MEDIUM-1: Add TODO(P0c-cleanup) removal marker on the backward-compat
block in build_index_from_snapshot with a link to tracking issue #417.
Prevents the fallback from silently persisting indefinitely after the
rolling-upgrade window closes.
MEDIUM-2: Add tracing::warn! in build_one when enforcement_mode is not
"block". Operators who set enforcement_mode="monitor" expecting pass-
through behavior will now see a log warning that the setting is not yet
implemented and the DP will block regardless.
@moonming
moonming merged commit 98e9835 into mainMay 27, 2026
8 checks passed
@moonming
moonming deleted the feat/p0c-guardrail-index branch May 27, 2026 00:31
jarvis9443 added a commit that referenced this pull request Aug 24, 2026
`mandatory` was never a designed feature. It arrived in #411 as one of
three schema columns the control plane's P0b added, carrying a doc
comment that said so outright: "Not yet implemented — the field is stored
and forwarded to the CP dashboard but the DP does not yet consult it;
`fail_open` alone governs error behavior in the current release." A
behaviour was retro-fitted to it five weeks later in #683, as a follow-up
to a security review.
Its whole documented job was overriding `fail_open` on the failure path.
Once `fail_open` began defaulting to false (#1040), that job was already
done by the default, and only two effects remained: resolving a
configuration the operator contradicted themselves in (`fail_open: true`
plus `mandatory: true`), and punching a hole through
`enforcement_mode: monitor` — which nothing ever specified. It fell out
of decorator ordering, and it read backwards: a monitored row would pass
content it had detected as harmful while refusing all traffic, harmless
included, because its provider was briefly unreachable. The mode meant to
be safe for evaluating a new rule was the one that could take a
deployment down.
Nobody could have been relying on it. The dashboard never exposed the
field, and the control plane is how every user configures aisix.
Monitor mode is unconditional again: a monitored row never blocks, for
any reason. An operator who wants an unreachable provider to refuse
traffic is asking for enforcement, which is `block` mode with
`fail_open: false` — one way to say it instead of two.
This also settles what #1384 asked. A monitored row cannot block, so
`EndOfStreamCheck` is the correct stream policy for it and there is
nothing to hold back.
Removed with it: the `MandatoryGuardrail` decorator, the
`keep_unavailable_fatal` exception #1040 added to `MonitorGuardrail` to
keep this guarantee alive, and the `preserves()` predicate that existed
only to keep the two in agreement.
Sign up for freeto 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

@moonming