Policy evaluation flow
Policy evaluation in Arbitex is a two-phase process. Phase 1 detects sensitive entities in the prompt or response text. Phase 2 evaluates the detected entities against a policy chain to determine what action to take. These phases are independent by design: all entity detection completes before any action is determined. The engine never short-circuits detection to act faster — you always get a complete picture of what was found before a decision is made.
This guide is aimed at engineers integrating with the policy engine and security architects designing policy chains. It covers the internal mechanics of both phases, the combining algorithms, rule condition semantics, action severity ordering, and audit trail generation.
Source: backend/app/services/policy_engine.py
1. Overview
Section titled “1. Overview”The two phases of policy evaluation are deliberately decoupled:
- Phase 1 — DLP Scan: Run all four detection tiers exhaustively against the request text. Collect every entity match. Deduplicate overlapping spans. Apply confidence threshold filtering. Produce a
DLPScanResult. - Phase 2 — Policy Chain Evaluation: Build a
PolicyContextfrom the DLP results and request metadata. Evaluate the applicable policy chains in order. Apply the combining algorithm to determine the finalPolicyDecision.
No rule can influence what gets detected. No detection result can skip rule evaluation. The boundary between Phase 1 and Phase 2 is a clean data handoff.
┌─────────────────────────────────────────────────────────────────┐│ PHASE 1: DLP SCAN ││ ││ Input text ││ │ ││ ├──► Tier 1: Regex (76 patterns, deterministic) ││ ├──► Tier 2: NER (spaCy en_core_web_sm, probabilistic) ││ ├──► Tier 3: DeBERTa NLI (contextual validation) ││ └──► Tier 4: CredInt (bloom filter, concurrent) ││ ││ All tiers run. All matches collected. ││ Deduplication + confidence threshold applied. ││ Output: DLPScanResult { matches: [...] } │└──────────────────────────────────┬──────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────────┐│ PHASE 2: POLICY CHAIN EVALUATION ││ ││ DLPScanResult + request metadata → PolicyContext ││ ││ 1. Evaluate user chain (scope="user") ││ 2. Evaluate org chain (scope="org") ││ ││ Combining algorithm: first_applicable or deny_overrides ││ ││ Output: PolicyDecision { action, matched_rule, ... } │└──────────────────────────────────┬──────────────────────────────┘ │ ▼ AuditLog entry written2. Phase 1: DLP Scan
Section titled “2. Phase 1: DLP Scan”The DLP scan runs four detection tiers. All four tiers run exhaustively against the full text — detection does not stop when one tier produces a match.
Tier 1: Regex pattern matching
Section titled “Tier 1: Regex pattern matching”76 compiled regex patterns covering structured PII and credential formats: SSNs, credit card numbers, IBANs, passport numbers, driver’s license formats by jurisdiction, API key prefixes, JWT patterns, and more. Regex detection is deterministic — a match either occurs or it does not. Confidence is always 1.0 for regex matches. Regex patterns are jurisdiction-aware: the US SSN pattern is distinct from the UK NI number pattern.
Tier 2: Named Entity Recognition
Section titled “Tier 2: Named Entity Recognition”spaCy en_core_web_sm NER model identifies contextually-recognized entities: PERSON names, ORG names, email addresses, phone numbers, dates, locations, and custom entity labels registered via the spaCy pipeline. NER is probabilistic — the model produces a confidence score for each entity. Unlike regex, NER understands context: “John Smith” in a medical record context scores higher than in a list of fictional characters.
Tier 3: DeBERTa NLI contextual validation
Section titled “Tier 3: DeBERTa NLI contextual validation”DeBERTa Natural Language Inference validates and potentially demotes Tier 2 NER findings by scoring whether the surrounding context entails the entity label. A detected PERSON entity might be demoted if DeBERTa determines the surrounding text is discussing a historical figure in an academic context rather than transmitting live PII.
This tier requires explicit opt-in:
DLP_DEBERTA_ENABLED=trueDEBERTA_MODEL_PATH=/path/to/deberta-v3-base-mnliWhen disabled, Tier 2 NER findings pass through unmodified.
Tier 4: Credential intelligence (CredInt)
Section titled “Tier 4: Credential intelligence (CredInt)”CredInt operates concurrently with Tiers 1–3. The Outpost runs a bloom filter check against a locally-synced index of known-compromised credentials. Only the first 8 hex characters of the SHA-1 hash of a candidate credential are transmitted for lookup — the full credential is never sent to any external service. CredInt produces a match record when a candidate credential matches the compromised index with a confidence score derived from the frequency bucket of that credential in known breach datasets.
Candidate credentials for CredInt lookup are sourced from Tier 1 regex matches whose entity type is in the CredInt-eligible set (e.g., API_KEY, OAUTH_TOKEN, PASSWORD_PATTERN). CredInt does not independently identify credential candidates — it validates regex candidates against the known-compromise index. This design means CredInt adds zero false-positive risk for entity types that are not first detected by Tier 1.
Deduplication and confidence filtering
Section titled “Deduplication and confidence filtering”After all tiers complete, the engine deduplicates matches:
- Same entity type, same span: only the match with the highest confidence score is kept.
- Different entity types at the same span: both matches are kept. A string that is simultaneously an EMAIL and a PERSON (e.g., a named email alias) produces two distinct match records.
- Overlapping but not identical spans: both kept. Downstream rule conditions can match on either.
After deduplication, matches with confidence below the threshold are dropped. The default threshold is 0.5. This threshold is configurable per-deployment via DLP_CONFIDENCE_THRESHOLD.
DLPScanResult structure
Section titled “DLPScanResult structure”@dataclassclass DLPScanResult: matches: list[DLPMatch]
@dataclassclass DLPMatch: detector_name: str # e.g. "regex_ssn_us", "ner_person", "deberta_nli" entity_type: str # e.g. "SSN", "PERSON", "API_KEY", "CREDIT_CARD" matched_text: str # the actual matched string start: int # character offset, inclusive end: int # character offset, exclusive confidence: float # 0.0 to 1.03. Phase 2: Policy Chain Evaluation
Section titled “3. Phase 2: Policy Chain Evaluation”Policy chain evaluation begins by assembling a PolicyContext from the DLP scan result and request metadata.
PolicyContext
Section titled “PolicyContext”PolicyContext is the complete state available to every rule condition. Rules can only observe what is in PolicyContext — they cannot query external systems at evaluation time.
| Field | Type | Description |
|---|---|---|
user_id |
UUID | Identity of the requesting user |
tenant_id |
UUID | Tenant (organization) identifier |
provider |
str | AI provider name, e.g. "anthropic", "openai" |
model |
str | Model name, e.g. "claude-opus-4-6" |
prompt_text |
str | Full text of the request (input) or response (output) |
detected_entities |
list[dict] | Entities from DLP scan: {entity_type, text, confidence, start, end} |
user_groups |
list[str] | SCIM/directory group memberships for the user |
user_risk_score |
float | Risk score from CredInt and GeoIP enrichment (0.0–1.0) |
direction |
str | "input" or "output" |
intent_complexity |
str or None | "simple", "medium", "complex", or None |
channel |
str | "interactive" or "api" (default "api") |
model_risk_tier |
str or None | From model registry; None indicates unclassified model |
content_categories |
list[str] | L1 keyword classifier category labels |
Chain evaluation order
Section titled “Chain evaluation order”Policy chains are evaluated in a fixed order that mirrors the Palo Alto firewall policy model — most specific to least specific:
- User chain (
scope="user",scope_id=user_id) — personal policy overrides for this specific user. - Org chain (
scope="org",scope_id=tenant_id) — the primary organizational policy chain applied to all users in the tenant.
If the user chain produces a terminal decision, the org chain is not evaluated. If the user chain produces no match (or only non-terminal REDACT accumulations), evaluation continues to the org chain.
How accumulated REDACTs carry across chains
Section titled “How accumulated REDACTs carry across chains”If the user chain accumulates one or more REDACT transformations but does not produce a terminal action, those accumulated redactions are preserved and passed into the org chain evaluation. The org chain then continues accumulating or terminates with its own decision. The final PolicyDecision.accumulated_redacts is the union of all REDACT transformations collected across both chains.
This means a user-chain REDACT rule and an org-chain REDACT rule can both fire in sequence, producing a decision that applies both transformations before forwarding the request.
User chain (first_applicable): R1 (seq=1): entity_types=["EMAIL"] → REDACT (non-terminal, accumulated) (no more matching rules — chain exhausted without terminal action)
Pass accumulated_redacts=[EMAIL redaction] to org chain.
Org chain (first_applicable): R1 (seq=1): entity_types=["SSN"] → REDACT (non-terminal, accumulated) R2 (seq=2): catch-all → ALLOW (terminal)
Final: PolicyDecision( action="ALLOW", accumulated_redacts=[EMAIL redaction, SSN redaction])→ Request forwarded with both EMAIL and SSN replaced.4. Combining Algorithms
Section titled “4. Combining Algorithms”Each policy chain specifies a combining algorithm that governs how multiple rules within the chain interact. Two algorithms are supported.
first_applicable (default)
Section titled “first_applicable (default)”Rules are evaluated in priority order. Lower sequence number = higher priority. The first rule whose conditions all match AND whose action is terminal determines the result. Evaluation stops immediately at that rule.
REDACT is non-terminal: if a rule matches and its action is REDACT, the transformation is accumulated but evaluation continues to the next rule.
Rule Set (first_applicable): R1 (seq=1): entity_types=["SSN"] → REDACT (non-terminal) R2 (seq=2): user_groups=["finance"] → ALLOW (terminal) R3 (seq=3): catch-all → BLOCK (terminal)
Evaluation for user NOT in finance, SSN detected:
R1: SSN detected? YES → REDACT accumulated, continue R2: user in finance? NO → skip R3: catch-all → BLOCK (terminal)
Result: BLOCK (with accumulated REDACT recorded in decision)
Evaluation for user IN finance, SSN detected:
R1: SSN detected? YES → REDACT accumulated, continue R2: user in finance? YES → ALLOW (terminal)
Result: ALLOW (R3 never reached)deny_overrides (XACML-style)
Section titled “deny_overrides (XACML-style)”All rules in the chain are evaluated regardless of earlier matches. Deny actions win over everything.
- BLOCK or CANCEL matched by any rule: immediately terminal — deny wins, stop evaluating.
- ALLOW or ROUTE_TO: collected but evaluation continues.
- REDACT: transformation accumulated, evaluation continues.
- If no deny found after all rules: return the most severe collected non-deny decision by severity score.
Rule Set (deny_overrides): R1 (seq=1): entity_types=["API_KEY"] → BLOCK (deny) R2 (seq=2): entity_types=["SSN"] → REDACT R3 (seq=3): catch-all → ALLOW
Evaluation for request with both SSN and API_KEY detected:
R1: API_KEY detected? YES → BLOCK matched → immediately terminal
Result: BLOCK (R2 and R3 never evaluated)
Evaluation for request with SSN only:
R1: API_KEY detected? NO → skip R2: SSN detected? YES → REDACT accumulated R3: catch-all → ALLOW collected
No deny found. Most severe non-deny: REDACT (severity 3) > ALLOW (severity 0).
Result: REDACT5. Rule Conditions
Section titled “5. Rule Conditions”Each rule has a set of conditions. All conditions within a single rule use AND logic — every condition in the rule must match for the rule to apply. If any condition fails, the rule is skipped.
applies_to pre-filter
Section titled “applies_to pre-filter”Before conditions are evaluated, the rule’s applies_to field is checked against context.direction:
applies_to value |
Applies when |
|---|---|
"input" |
context.direction == "input" only |
"output" |
context.direction == "output" only |
"both" |
Always (default) |
If applies_to does not match the current direction, the rule is skipped entirely — conditions are not evaluated.
Condition reference
Section titled “Condition reference”| Condition key | Match logic |
|---|---|
user_groups |
User is a member of ANY group in the listed values |
entity_types |
At least one detected entity has a type in the list AND confidence >= entity_confidence_min (default 0.5) |
content_regex |
re.search(pattern, context.prompt_text) returns a match |
providers |
context.provider is in the listed values |
models |
context.model is in the listed values |
user_risk_score_min |
context.user_risk_score >= threshold |
intent_complexity |
Exact string match against context.intent_complexity |
channel |
context.channel is in the listed values |
model_risk_tier |
Tier value (or "unclassified" when context.model_risk_tier is None) is in the listed values |
content_categories |
Any listed category is a prefix of any value in context.content_categories |
No conditions = unconditional catch-all
Section titled “No conditions = unconditional catch-all”A rule with no conditions defined always matches. This is used intentionally as the last rule in a chain to define a default action (commonly ALLOW or BLOCK) for any request that no earlier rule matched.
entity_confidence_min
Section titled “entity_confidence_min”The entity_types condition supports an optional entity_confidence_min field per rule (distinct from the global DLP confidence threshold). This allows a rule to require higher confidence for a match:
conditions: entity_types: - SSN - CREDIT_CARD entity_confidence_min: 0.8 # only match if confidence >= 0.8The global DLP threshold (default 0.5) filters matches before they reach the policy engine. entity_confidence_min applies a second, rule-specific filter on top of that.
Condition evaluation example
Section titled “Condition evaluation example”The following shows how a multi-condition rule is evaluated. All conditions use AND logic:
Rule: applies_to: "input" conditions: providers: ["anthropic", "openai"] entity_types: ["SSN"] entity_confidence_min: 0.75 user_groups: ["contractors"] user_risk_score_min: 0.4
Evaluation: 1. applies_to="input" vs direction="input" → PASS 2. providers: context.provider="anthropic" in ["anthropic", "openai"] → PASS 3. entity_types: SSN detected at confidence 0.82 >= 0.75 → PASS 4. user_groups: user is in "contractors" → PASS 5. user_risk_score_min: user_risk_score=0.61 >= 0.4 → PASS
All 5 conditions passed → rule MATCHES → action applied.If any single condition fails (e.g., the user is not in contractors), the entire rule is skipped regardless of how many conditions passed.
6. Action Hierarchy
Section titled “6. Action Hierarchy”Actions have a numeric severity score used by the deny_overrides algorithm to determine which action wins when no deny is found.
ACTION_SEVERITY = { "ALLOW": 0, "PROMPT": 1, "ROUTE_TO": 2, "REDACT": 3, "CANCEL": 4, "BLOCK": 5,}Higher severity wins. In deny_overrides, if no BLOCK or CANCEL is found and multiple non-deny actions were collected, the action with the highest severity score is returned as the final decision.
Action semantics
Section titled “Action semantics”| Action | Meaning |
|---|---|
ALLOW |
Request proceeds unmodified |
ALLOW_WITH_OVERRIDE |
Request proceeds; user acknowledged a policy warning |
PROMPT |
User must acknowledge a policy warning before proceeding |
ROUTE_TO |
Request is transparently rerouted to a different model |
REDACT |
Matched entities are replaced with redaction placeholders before forwarding |
CANCEL |
Request is cancelled (client receives error); not a hard security block |
BLOCK |
Request is blocked and logged as a policy violation |
ROUTE_TO routing semantics
Section titled “ROUTE_TO routing semantics”ROUTE_TO redirects the request to an alternate model without informing the user. The PolicyDecision carries either route_to_model (specific model name) or route_to_tier (model tier, resolved to the tenant’s configured default model for that tier at dispatch time). If both are set, route_to_model takes precedence.
A common pattern is routing high-risk-score users to a monitored model with reduced capability:
conditions: user_risk_score_min: 0.7action: ROUTE_TOroute_to_tier: "monitored"PROMPT and ALLOW_WITH_OVERRIDE
Section titled “PROMPT and ALLOW_WITH_OVERRIDE”PROMPT presents the user with an admin-configured message and requires them to click through before the request proceeds. This is the soft-block pattern: the request is not denied, but the user must acknowledge the policy concern.
ALLOW_WITH_OVERRIDE is used when a user has already acknowledged a PROMPT — the second evaluation (after acknowledgment) produces ALLOW_WITH_OVERRIDE rather than PROMPT to indicate the override was recorded. Both the original PROMPT and the subsequent ALLOW_WITH_OVERRIDE appear in the audit log, forming a consent trail.
prompt_message in PolicyDecision contains the admin-configured message text displayed to the user.
7. Terminal vs Non-Terminal Actions
Section titled “7. Terminal vs Non-Terminal Actions”The terminal/non-terminal distinction matters only in first_applicable mode.
Terminal actions (stop the chain)
Section titled “Terminal actions (stop the chain)”All of the following actions are terminal in first_applicable:
ALLOWALLOW_WITH_OVERRIDEBLOCKCANCELROUTE_TOPROMPT
When a rule matching one of these actions is reached, evaluation stops. The chain does not continue to lower-priority rules.
Non-terminal actions (accumulate and continue)
Section titled “Non-terminal actions (accumulate and continue)”REDACT is the only non-terminal action. When a rule matches with REDACT:
- The redaction transformation is appended to
accumulated_redactsin the building decision. - Evaluation continues to the next rule in priority order.
- Multiple REDACT rules can accumulate multiple transformations in a single pass.
- The final terminal rule determines the
actionfield inPolicyDecision. If the chain reaches a terminal ALLOW, the accumulated redactions are still applied (the request is allowed but with redacted content).
8. Worked Examples
Section titled “8. Worked Examples”Example 1: Single entity (SSN), first_applicable, user not in finance group
Section titled “Example 1: Single entity (SSN), first_applicable, user not in finance group”Rule chain (first_applicable):
| Seq | Conditions | Action |
|---|---|---|
| 1 | entity_types: ["SSN"] |
REDACT |
| 2 | entity_types: ["SSN"], user_groups: ["finance"] |
ALLOW |
| 3 | (catch-all) | BLOCK |
Request: User is not a member of the finance group. SSN detected by Tier 1 regex, confidence 0.92.
Evaluation trace:
R1 (seq=1): entity_types=["SSN"] → SSN present at confidence 0.92 >= 0.5 ✓ Action: REDACT (non-terminal) → accumulated_redacts += SSN redaction Continue to next rule.
R2 (seq=2): entity_types=["SSN"] → SSN present ✓ user_groups=["finance"] → user NOT in finance ✗ Condition failed → skip.
R3 (seq=3): (no conditions) → catch-all matches ✓ Action: BLOCK (terminal) → stop.
Final: PolicyDecision(action="BLOCK", accumulated_redacts=[{entity_type: "SSN", ...}])The REDACT from R1 is recorded in the decision but the request is blocked — the redaction was accumulated but not applied since BLOCK is the terminal outcome.
Example 2: Multi-entity (SSN + API_KEY), deny_overrides
Section titled “Example 2: Multi-entity (SSN + API_KEY), deny_overrides”Rule chain (deny_overrides):
| Seq | Conditions | Action |
|---|---|---|
| 1 | entity_types: ["API_KEY"] |
BLOCK |
| 2 | entity_types: ["SSN"] |
REDACT |
| 3 | (catch-all) | ALLOW |
Request: Both SSN (confidence 0.87) and API_KEY (confidence 1.0) detected.
Evaluation trace:
R1 (seq=1): entity_types=["API_KEY"] → API_KEY present at confidence 1.0 ✓ Action: BLOCK → deny action → immediately terminal, stop.
R2 and R3 never evaluated.
Final: PolicyDecision(action="BLOCK", matched_rule_id=<R1 id>, match_reason="API_KEY detected")In deny_overrides, a single BLOCK from any rule wins immediately. Even though SSN was also detected and R2 would have produced a REDACT, the BLOCK from R1 takes precedence and stops evaluation.
Example 3: Entity detected, no rule matches
Section titled “Example 3: Entity detected, no rule matches”Rule chain (first_applicable):
| Seq | Conditions | Action |
|---|---|---|
| 1 | entity_types: ["SSN"] |
BLOCK |
| 2 | entity_types: ["CREDIT_CARD"] |
REDACT |
Request: EMAIL address detected (confidence 0.78). No SSN, no CREDIT_CARD.
Evaluation trace:
R1 (seq=1): entity_types=["SSN"] → no SSN in detected_entities ✗ → skip.R2 (seq=2): entity_types=["CREDIT_CARD"] → no CREDIT_CARD ✗ → skip.
No rules matched. Chain exhausted.
Final: PolicyDecision( action="ALLOW", matched_rule_id=None, match_reason="no policy match -- default ALLOW")The EMAIL entity is recorded in the audit trail with its confidence score. The request passes. This is expected behavior: if your policy chain does not address a particular entity type, the default outcome is ALLOW. If you want a default-deny posture, add a catch-all BLOCK rule as the last rule in the chain.
9. PolicyDecision Reference
Section titled “9. PolicyDecision Reference”PolicyDecision is the dataclass returned by the policy engine to the request handler. All fields are recorded in the audit log.
@dataclassclass PolicyDecision: action: str # ALLOW, BLOCK, CANCEL, REDACT, # ROUTE_TO, PROMPT, ALLOW_WITH_OVERRIDE matched_pack_id: UUID | None # policy pack that contained the matching rule matched_rule_id: UUID | None # specific rule that determined the outcome matched_sequence: int | None # priority number of the matched rule match_reason: str # human-readable explanation redact_replacement: str | None # replacement string for single-rule REDACT route_to_model: str | None # target model for ROUTE_TO route_to_tier: str | None # target model tier for ROUTE_TO message: str | None # message returned to client on BLOCK/CANCEL eval_duration_ms: float # wall-clock evaluation time in milliseconds accumulated_redacts: list[dict] # all REDACT transformations accumulated prompt_message: str | None # admin-configured message for PROMPT / # ALLOW_WITH_OVERRIDE actionsExample: BLOCK decision
Section titled “Example: BLOCK decision”{ "action": "BLOCK", "matched_pack_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "matched_rule_id": "11223344-5566-7788-99aa-bbccddeeff00", "matched_sequence": 1, "match_reason": "entity type API_KEY detected at confidence 1.00", "redact_replacement": null, "route_to_model": null, "route_to_tier": null, "message": "Your request was blocked because it contained a credential. Remove credentials from prompts and retry.", "eval_duration_ms": 4.2, "accumulated_redacts": [], "prompt_message": null}Example: REDACT decision
Section titled “Example: REDACT decision”{ "action": "REDACT", "matched_pack_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "matched_rule_id": "aabbccdd-eeff-0011-2233-445566778899", "matched_sequence": 2, "match_reason": "entity type SSN detected at confidence 0.92", "redact_replacement": "[REDACTED-SSN]", "route_to_model": null, "route_to_tier": null, "message": null, "eval_duration_ms": 6.8, "accumulated_redacts": [ { "entity_type": "SSN", "matched_text": "078-05-1120", "start": 42, "end": 53, "replacement": "[REDACTED-SSN]", "rule_id": "aabbccdd-eeff-0011-2233-445566778899" } ], "prompt_message": null}10. Audit Trail
Section titled “10. Audit Trail”Every request evaluated by the policy engine generates an AuditLog entry. The audit log is the authoritative record for compliance reporting, incident investigation, and chain-of-custody verification.
Entries are written synchronously as part of the request lifecycle — the request handler does not return a response to the client until the audit entry has been committed. This ensures there is no window in which a request can be processed but not logged.
AuditLog schema
Section titled “AuditLog schema”| Field | Type | Description |
|---|---|---|
action |
str | Event type, e.g. "prompt_sent", "response_blocked" |
model_id |
UUID | Model registry identifier |
provider |
str | AI provider name |
user_id |
UUID | Requesting user |
tenant_id |
UUID | Tenant (organization) |
metadata |
JSONB | Detected entities, confidences, matched rule details |
hmac |
str | HMAC-SHA256 digest of this entry |
previous_hmac |
str | Digest of the preceding entry (chain linkage) |
hmac_key_id |
str | Key identifier for HMAC rotation |
created_at |
timestamptz | Entry creation timestamp with timezone |
CredInt fields
Section titled “CredInt fields”| Field | Type | Description |
|---|---|---|
credint_enabled |
bool | Whether CredInt was active for this request |
credint_hit |
bool | Whether a compromised credential was detected |
frequency_bucket |
str | Breach frequency tier of the matched credential |
context_type |
str | Context classification used by CredInt |
sha1_prefix |
str | First 8 hex characters of the SHA-1 hash only — never the full hash |
credint_confidence |
float | CredInt match confidence score |
GeoIP fields
Section titled “GeoIP fields”| Field | Description | In HMAC chain |
|---|---|---|
src_ip |
Source IP address (inet) | Yes |
dst_ip |
Destination IP address (inet) | Yes |
country |
GeoIP-resolved country | No |
region |
GeoIP-resolved region | No |
city |
GeoIP-resolved city | No |
isp |
ISP name | No |
asn |
Autonomous system number | No |
HMAC chain integrity
Section titled “HMAC chain integrity”Each audit entry contains an HMAC-SHA256 digest computed over a canonical serialization of the entry’s tamper-evident fields. That digest is stored in the entry’s hmac field and also written to the next entry’s previous_hmac field, forming a hash chain analogous to a blockchain ledger.
Chain properties:
- Tamper detection: Any modification to a past entry invalidates its HMAC, which breaks the chain from that point forward. Verification tools traverse the chain from oldest to newest and report the first break.
- Key rotation:
hmac_key_idrecords which key was active when the entry was signed. Key rotation does not break chain continuity — entries signed with old keys are verified with the old key, new entries with the new key. - What is in the chain:
user_id,tenant_id,action,provider,model_id,src_ip,dst_ip,metadata(including detected entities and matched rule),created_at, andprevious_hmac. GeoIP enrichment fields are excluded.
Entry N-1 Entry N Entry N+1┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│ ...fields... │ │ ...fields... │ │ ...fields... ││ hmac: H(N-1) │──────► │ previous_hmac: │ │ previous_hmac: ││ │ │ H(N-1) │──────► │ H(N) ││ │ │ hmac: H(N) │ │ hmac: H(N+1) │└─────────────────┘ └─────────────────┘ └─────────────────┘To verify chain integrity for a tenant, iterate all entries in created_at order and re-compute HMAC for each entry using the key referenced by hmac_key_id. Compare against the stored hmac. Verify the previous_hmac of entry N matches the hmac of entry N-1.
Querying the audit log for policy decisions
Section titled “Querying the audit log for policy decisions”The metadata JSONB field is indexed with GIN for efficient querying. Common audit queries:
-- All BLOCK decisions for a tenant in the last 7 daysSELECT created_at, user_id, metadataFROM audit_logsWHERE tenant_id = $1 AND action = 'prompt_blocked' AND created_at > now() - interval '7 days'ORDER BY created_at DESC;
-- All requests that triggered SSN detectionSELECT created_at, user_id, action, metadataFROM audit_logsWHERE tenant_id = $1 AND metadata @> '{"detected_entities": [{"entity_type": "SSN"}]}'ORDER BY created_at DESC;
-- CredInt hits in the last 30 daysSELECT created_at, user_id, credint_confidence, frequency_bucket, sha1_prefixFROM audit_logsWHERE tenant_id = $1 AND credint_hit = true AND created_at > now() - interval '30 days'ORDER BY credint_confidence DESC;11. What users see when a policy triggers
Section titled “11. What users see when a policy triggers”The user receives an error response. The block message is configurable per rule:
{ "error": "Request blocked by policy", "message": "This request contains content that is not permitted under your organization's policy."}The specific message text is set by the admin in the rule’s action configuration.
CANCEL
Section titled “CANCEL”The request is silently cancelled. The user receives a generic error (similar to a network timeout). No block message is shown. Use CANCEL when you want to suppress a request without revealing that a policy matched.
REDACT
Section titled “REDACT”The user’s prompt text reaches the model with matched content replaced by the configured redaction string (e.g., [REDACTED]). The user is not notified that redaction occurred unless the admin has configured a notification. The model responds based on the redacted text.
ROUTE_TO
Section titled “ROUTE_TO”The request is forwarded to a different model than the user requested — either a specific model (route_to_model) or a tier (haiku, sonnet, opus). The user receives a response, but from the routed model. No notification is shown to the user.
PROMPT (interactive channel only)
Section titled “PROMPT (interactive channel only)”The user is presented with a governance challenge dialog before the request proceeds:
[Admin-configured challenge message]
[ Acknowledge and continue ] [ Cancel ]If the user acknowledges, the request is allowed. If the user cancels, the request is not sent. PROMPT only fires for channel=interactive requests. API channel requests are not affected by PROMPT rules and receive HTTP 449 directly.
ALLOW_WITH_OVERRIDE
Section titled “ALLOW_WITH_OVERRIDE”Similar to PROMPT, but the user sees an override acknowledgment message and explicitly consents before the request proceeds:
[Admin-configured override message]
[ I understand, continue ] [ Cancel ]12. ROUTE_TO configuration patterns
Section titled “12. ROUTE_TO configuration patterns”ROUTE_TO redirects the request to a different model or model tier when a rule matches. Use it for cost-based routing, capability-based routing, or risk-based routing.
Route to a specific model
Section titled “Route to a specific model”{ "type": "ROUTE_TO", "route_to_model": "claude-haiku-4-5-20251001"}Route to a tier
Section titled “Route to a tier”{ "type": "ROUTE_TO", "route_to_tier": "haiku"}Valid tier values: haiku, sonnet, opus. The engine resolves the tier to the configured model for that tier at evaluation time. If both route_to_model and route_to_tier are set, route_to_model takes precedence.
ROUTE_TO example: intent-based downgrade
Section titled “ROUTE_TO example: intent-based downgrade”Route simple requests to a lower-cost model:
Pack: "Cost Optimization" Rule 1: intent_complexity=simple → ROUTE_TO tier=haiku Rule 2: intent_complexity=complex → ROUTE_TO tier=opus (no rule for medium → falls through to default model)ROUTE_TO example: risk-score-based routing
Section titled “ROUTE_TO example: risk-score-based routing”Route elevated-risk users to a restricted model:
Pack: "Risk-Based Routing" Rule: user_risk_score_min=0.7, providers=[openai] → ROUTE_TO model=gpt-4o-mini13. User chain vs org chain — practical distinction
Section titled “13. User chain vs org chain — practical distinction”Org chain
Section titled “Org chain”The org chain (scope="org") is the primary policy chain for your organization. All users in the org are subject to it. Rules in the org chain can use user_groups conditions to target specific groups without requiring separate per-user chains.
Org chain (first_applicable): Pack: "Compliance Baseline" Rule 1: entity_types=[SSN, CREDIT_CARD] → BLOCK (applies to all users) Rule 2: user_groups=["finance"], entity_types=[BANK_ACCOUNT] → ALLOW (finance team exempt) Rule 3: entity_types=[BANK_ACCOUNT] → BLOCK (all other users)User chain
Section titled “User chain”The user chain (scope="user") contains overrides for a specific user. It is evaluated first — before the org chain. A terminal action in the user chain prevents the org chain from being evaluated at all.
Use user chains sparingly. The recommended pattern is to express group-level overrides via user_groups conditions in the org chain rather than creating individual user chains.
14. Common configuration patterns
Section titled “14. Common configuration patterns”Pattern 1: DLP baseline with group override
Section titled “Pattern 1: DLP baseline with group override”Block PII for all users, but allow the security-audit group with an acknowledged override:
Org chain (first_applicable): Pack: "Security Audit Override" (sequence 1) Rule: user_groups=["security-audit"] → ALLOW_WITH_OVERRIDE Pack: "DLP Baseline" (sequence 2) Rule: entity_types=[SSN, CREDIT_CARD, PASSPORT] → BLOCKPattern 2: Cost-tiered routing with compliance block
Section titled “Pattern 2: Cost-tiered routing with compliance block”Implement cost optimization routing but ensure compliance blocks always fire using deny_overrides:
Org chain (deny_overrides): Pack: "Cost Routing" (sequence 1) Rule: intent_complexity=simple → ROUTE_TO tier=haiku Rule: intent_complexity=complex → ROUTE_TO tier=opus Pack: "Compliance Block" (sequence 2) Rule: content_regex="export controlled|ITAR|EAR" → BLOCKWith deny_overrides, the compliance block in Pack 2 fires even if Pack 1 has already produced a routing decision.
Pattern 3: Channel-gated PROMPT governance
Section titled “Pattern 3: Channel-gated PROMPT governance”Present a governance challenge only for interactive users, not API callers:
Org chain (first_applicable): Pack: "Interactive Governance" Rule: channel=["interactive"], content_regex="generate.*code" → PROMPT prompt_message="Code generation requires confirmation. Proceed?"Pattern 4: Risk score escalation
Section titled “Pattern 4: Risk score escalation”Route high-risk users to a restricted model regardless of their request:
Org chain (first_applicable): Pack: "Risk Escalation" Rule: user_risk_score_min=0.8 → ROUTE_TO model=gpt-4o-mini Pack: "Default Policy" Rule: (no conditions — catch-all) → ALLOW