-
Notifications
You must be signed in to change notification settings - Fork 1
multi domain guardrails design
Status: design document. Nothing in this file has been implemented against
HIPAAGuardrailRegistry.solorbcc.rego— both are unchanged. This proposes how to generalize the existing single-domain (HIPAA) OPA+registry pattern into a pluggable, multi-domain one, grounded in what the repo already has:contracts/src/framework/DomainRegistry.sol(agents already join adomainId),bcc_middleware's OPA integration, andcontracts/src/health/HIPAAGuardrailRegistry.sol's on-chain audit-anchor pattern.Source files read to ground this:
contracts/src/health/HIPAAGuardrailRegistry.sol,bcc_middleware/policies/bcc.rego,bcc_middleware/policies/bcc_test.rego,bcc_middleware/app/{main,opa_client,config,schemas,chain}.py,contracts/src/framework/{DomainRegistry,AgentPrimitivesFactory,XibalbaAgentRegistry}.sol,contracts/src/health/SmartBAAFactory.sol,docs/wiki/concepts/compliance-gate.md,docs/INTERFACE_CONTRACT.md§7, andintegrity-oracle/backend/src/handlers.rs(confirmsGET /v1/agent/{id}already returns a server-verifieddomain_id).
Surveyed: NVIDIA NeMo Guardrails, Guardrails AI, Meta LlamaGuard, OpenAI Moderation API, AWS Bedrock Guardrails, Microsoft Presidio. Despite very different implementations, they converge on the same four properties:
| Property | NeMo Guardrails | Guardrails AI | LlamaGuard | OpenAI Moderation | Bedrock Guardrails | Presidio |
|---|---|---|---|---|---|---|
| (a) Domain/category-scoped | Colang "rails" grouped by flow (jailbreak, topical, fact-checking, each its own .co file) |
Validators grouped per RAIL spec / Hub category (PII, toxicity, competitor mentions, ...) | Single classifier, but its taxonomy (violence, hate, self-harm, ...) is itself category-scoped | Fixed category set (hate, harassment, self-harm, sexual, violence) — not pluggable, a fixed list | Named "guardrail configs": denied topics, content filters, word filters, PII entities — each independently configured per guardrail resource | Pluggable "recognizers," each scoped to one PII entity type (SSN, credit card, name, ...) |
| (b) Independently enable/disable | Rails are toggled per-flow in config.yml
|
Validators added/removed per Guard instance | N/A (one model, not decomposed) | N/A (fixed, not configurable) | Each filter/topic/word-list toggled per guardrail version | Recognizers registered/deregistered per AnalyzerEngine instance |
| (c) Lifecycle evaluation point | Explicit rail types: input rails (before LLM sees it), dialog rails (mid-conversation), output rails (before user sees it), execution rails (before/after tool calls) | Input guards (pre-prompt) and output guards (post-completion), run via Guard.__call__ wrapping the LLM call |
Typically both input (prompt) and output (completion) classification passes | Pre-generation call against user input (usually) |
ApplyGuardrail API called at either INPUT or OUTPUT stage, explicit source param |
Called wherever the integrating app chooses — typically pre-storage or pre-LLM-context |
| (d) Structured verdict, not boolean | Rail returns an action (bot refuse, stop) + which rail fired |
ValidationOutcome (pass/fail per validator) + optionally a fixed/reasked value |
Category label + severity, not just allow/deny | Per-category boolean flags + confidence scores, not one aggregate boolean |
{action: NONE|GUARDRAIL_INTERVENED, assessments: [...]} — one assessment per triggered policy, each naming which filter/topic fired and why |
List of RecognizerResult (entity type, span, confidence) — a redaction plan, not a boolean |
The common architectural shape, stripped of vendor-specific vocabulary:
policy set (domain-scoped, independently toggleable)
│
▼
evaluated at a named lifecycle hook (pre-request / pre-tool-call / post-response)
│
▼
structured verdict: {allow|block|redact, reason(s), which-policy-fired}
This repo's existing bcc.rego + bcc_middleware + HIPAAGuardrailRegistry
already implements exactly this shape for one domain (HIPAA):
-
(a) domain-scoped — hardcoded to
integrity.bcc, HIPAA-only. No pluggability today. - (b) toggleable — not per-policy; the whole bundle is load-or-don't.
-
(c) lifecycle point — already correct and general: step 5 of
run_intercept(app/main.py), pre-execution, evaluated once perPOST /v1/bcc/intercept. This does not need to change. -
(d) structured verdict — already correct and general:
OPADecision{allow, violations, requires_baa}(app/opa_client.py) is already an "allow/deny + reasons + a domain-specific obligation flag," not a bare boolean. This also does not need to change shape, only be extended (see Part 2).
So the generalization needed is narrowly about (a) and (b): make the policy bundle domain-pluggable, and make bundle selection driven by which domain(s) the requesting agent is actually in — not about redesigning the request lifecycle or the verdict shape, both of which are already industry-standard-shaped.
One divergence from every vendor surveyed, worth calling out because it's a
strength, not a gap to close: none of NeMo/Guardrails-AI/Bedrock/Presidio
anchor which policy version governed a given decision anywhere
tamper-evident — their audit trail is application logs. HIPAAGuardrailRegistry
already does something none of them do (an on-chain, third-party-verifiable
"policy version X was active when decision Y was made" record). The design
below preserves and generalizes that, it doesn't discard it.
bcc.rego's own verification_tier gate is emphatic that
input.verification_tier must come from resolve_verification_tier
(oracle-resolved, "never client-asserted") rather than a field the
commitment itself carries — because a self-asserted tier is worthless as a
guardrail. The exact same failure mode applies to domain selection: if a
healthcare agent's commitment could simply say "domain": "general", it
would evade every HIPAA rule by asserting the weakest bundle applies. A
signed domain field doesn't fix this either — the agent still picks which
bundle it wants held accountable to.
This repo already has the fix, and it's already wired: AgentPrimitivesFactory .registerPrimitives requires a domainId and calls
DomainRegistry.recordJoin/XibalbaAgentRegistry.registerPrimitives, and
integrity-oracle's GET /v1/agent/{id} already returns that domain_id —
re-verified against XibalbaAgentRegistry.resolveDID on-chain, per this
repo's "the chain is the source of truth" rule (CLAUDE.md, oracle section).
So domain resolution for guardrail purposes should be oracle-resolved,
exactly like resolve_verification_tier, not a field on BCCCommitment.
Design decision: bcc_middleware evaluates the union of:
- A mandatory, always-on
generalbaseline bundle (every agent, every domain), and - The domain-specific bundle for the agent's oracle-resolved
domain_id.
A request is denied if either bundle denies. A commitment MAY optionally
carry a client-side domain_hint for logging/telemetry, but a hint can only
ever narrow which additional bundles get consulted for defense-in-depth —
it must never be used to skip the oracle-resolved domain's bundle. (Today,
XibalbaAgentRegistry stores exactly one domainId per agent, set at
registration — so in practice this is "general baseline + the agent's one
home-domain bundle," not an open-ended list. DomainRegistry.isMember is
already keyed generically enough that if the protocol later lets an agent
join multiple domains, this design extends to "general + every domain the
agent is a member of" with no further change.)
Today opa run --server policies/ loads the whole policies/ directory as
one OPA server holding a single package, integrity.bcc. The generalization:
-
Keep
bcc.regoand its package name (integrity.bcc) exactly as-is.docs/INTERFACE_CONTRACT.md§7 pinsPOST {OPA_URL}/v1/data/integrity/bcc/allowas the cross-package contract endpoint (integrity-sdkcalls this path directly, perbcc_middleware/README.md's "Integration reconciliation" §1). Renaming the package would be a breaking cross-package change requiring a coordinated migration acrossintegrity-sdk/integrity-cli, which is out of scope for a design doc. Instead,integrity.bccbecomes the healthcare domain bundle by convention/documentation (a comment at the top of the file noting this), not by a file or package rename. This is the one place this design deliberately does NOT "clean up" naming, in favor of not breaking a frozen interface. -
New sibling bundles, each its own file + package, following
bcc.rego's exact shape (default allow := false,allow if count(violation) == 0,violation contains msg if {...}, arequires_*signal rule where the domain needs one):-
policies/general.rego— packageintegrity.general. Always-on baseline (see 2.4 for content). -
policies/finance.rego— packageintegrity.finance. New finance vertical (see 2.4). - Future:
policies/<domain>.rego, packageintegrity.<domain>, one file per domain, all loaded by the sameopa run --server policies/(OPA loads every.regofile in the directory into one in-memory tree keyed by package — no per-file registration step needed).
-
-
Domain → package-path mapping lives in
bcc_middleware, not in Rego — Rego bundles stay domain-local and don't need to know about each other or about a routing table.
The task prompt suggests checking whether a factory pattern (like
AgentPrimitivesFactory/SmartBAAFactory) fits here. It doesn't, and the
reason is instructive: SmartBAAFactory deploys a new contract per pair
because each BAA is its own escrow with independent collateral and a status
state machine — genuinely separate contract instances with separate balances.
AgentPrimitivesFactory clones because each agent must self-own its
primitives (that's the whole self-sovereignty argument in CLAUDE.md).
Neither reason applies here: a guardrail anchor is just data (a policy hash +
version + an audit log), and every domain's anchor is governed by the same
protocol-level admin/oracle roles, not owned per-agent or per-pair. Note also
that ComplianceGateFactory does not exist as a real contract in
contracts/src/health/ today (only script/FixComplianceGateFactory.s.sol,
a deploy/ops script) — it isn't precedent to build on here.
The right shape mirrors DomainRegistry.sol itself: one registry, keyed by
domainId (keccak256(bytes(domainName)), reusing DomainRegistry's own
hashing convention so a domainId means the same thing everywhere in the
protocol):
// contracts/src/health/GuardrailRegistry.sol (DESIGN ONLY — not implemented)
pragma solidity ^0.8.28;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
/// @title GuardrailRegistry
/// @notice Generalizes HIPAAGuardrailRegistry to be domain-parameterized: one
/// active-policy anchor + one audit log per domainId (the same bytes32
/// identifier DomainRegistry already mints via keccak256(bytes(domainName))),
/// instead of one hardcoded global HIPAA anchor. HIPAAGuardrailRegistry is
/// left in place, unmodified, as the "healthcare" domain's anchor for
/// backward compatibility with anything already pointed at it (e.g.
/// EHRGate's existing wiring, per compliance-gate.md) -- this is an
/// ADDITIONAL, general-purpose registry for every other domain, not a
/// replacement migration.
contract GuardrailRegistry is AccessControl {
bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE");
struct PolicyAnchor {
bytes32 policyHash;
string version;
uint256 activeSince;
}
/// @dev domainId => currently active policy bundle for that domain.
/// A domainId with policyHash == 0 has no active guardrail bundle
/// (bcc_middleware should treat "no anchor" as "domain not yet
/// on-chain-governed", NOT as "no guardrail" -- see §2.3 fail-closed note).
mapping(bytes32 => PolicyAnchor) public activePolicy;
struct AuditEntry {
bytes32 domainId;
address agent;
// Generalizes HIPAAGuardrailRegistry's patientRecordHash: a
// domain-neutral hash of whatever resource/subject the decision was
// about (a patient record for healthcare, a transaction for
// finance, a tool-call payload for general -- the caller decides
// what "the subject" means per domain, this contract just anchors
// its hash).
bytes32 subjectHash;
bytes32 policyHash;
bool allowed;
uint256 timestamp;
}
AuditEntry[] public auditLog;
/// @dev domainId => indices into auditLog, for cheap per-domain audit queries.
mapping(bytes32 => uint256[]) public auditLogByDomain;
event PolicyActivated(bytes32 indexed domainId, bytes32 indexed policyHash, string version, uint256 timestamp);
event AccessAudited(
uint256 indexed entryIndex,
bytes32 indexed domainId,
address indexed agent,
bytes32 subjectHash,
bytes32 policyHash,
bool allowed
);
error StalePolicyHash();
error NoActivePolicy();
constructor(address admin, address oracle) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
if (oracle != address(0)) _grantRole(ORACLE_ROLE, oracle);
}
function setActivePolicy(bytes32 domainId, bytes32 policyHash, string calldata version)
external
onlyRole(DEFAULT_ADMIN_ROLE)
{
activePolicy[domainId] = PolicyAnchor({policyHash: policyHash, version: version, activeSince: block.timestamp});
emit PolicyActivated(domainId, policyHash, version, block.timestamp);
}
function anchorAccessAudit(bytes32 domainId, address agent, bytes32 subjectHash, bytes32 policyHashUsed, bool allowed)
external
onlyRole(ORACLE_ROLE)
returns (uint256 entryIndex)
{
PolicyAnchor memory anchor = activePolicy[domainId];
if (anchor.policyHash == bytes32(0)) revert NoActivePolicy();
if (policyHashUsed != anchor.policyHash) revert StalePolicyHash();
entryIndex = auditLog.length;
auditLog.push(AuditEntry(domainId, agent, subjectHash, policyHashUsed, allowed, block.timestamp));
auditLogByDomain[domainId].push(entryIndex);
emit AccessAudited(entryIndex, domainId, agent, subjectHash, policyHashUsed, allowed);
}
function auditLogLength() external view returns (uint256) { return auditLog.length; }
function auditLogLengthForDomain(bytes32 domainId) external view returns (uint256) {
return auditLogByDomain[domainId].length;
}
}Deploy once (singleton, like DomainRegistry), register in
deployments.local.json's singletons section (§6.6 shape), admin activates
a PolicyAnchor per domain as bundles are authored (general, finance,
...; healthcare stays on the existing HIPAAGuardrailRegistry unless/until
a deliberate migration ships). bcc_middleware computes
policyHash = keccak256(concatenated .rego sources for that domain's bundle)
the same way it presumably already does (or should) for
HIPAAGuardrailRegistry.setActivePolicy — this design doesn't change that
mechanic, just parameterizes it by domainId.
Two files change; the request-lifecycle position (step 5 in main.py) does
not move.
app/chain.py — add a resolve_agent_domain function, same shape as
resolve_verification_tier (oracle-resolved, fails to a safe default rather
than raising, since a transient oracle hiccup must deny gracefully via the
general baseline still applying, not take down every commitment):
def resolve_agent_domain(agent_id: str, *, oracle_url: str) -> str | None:
"""
Server-verified domain_id for agent_id, from GET /v1/agent/{id} (the
oracle already returns this -- see handlers.rs's AgentRecord.domain_id,
re-verified against XibalbaAgentRegistry.resolveDID on-chain). Never
client-asserted, same reasoning as resolve_verification_tier. Returns
None (not a made-up domain) on any resolution failure -- the caller
still evaluates the mandatory `general` bundle even when a vertical
bundle can't be resolved; it must NOT skip domain-specific gating by
inventing a default domain.
"""
... # httpx GET, return data.get("domain_id"), except -> Noneapp/opa_client.py — parameterize evaluate() by package path (it
already takes settings, which already carries opa_package_path; add an
explicit package_path: str parameter instead of always reading
settings.opa_package_path), and add a small helper that evaluates a list
of domain package paths and combines verdicts:
DOMAIN_PACKAGE_PATHS = {
"general": "/v1/data/integrity/general",
"healthcare": "/v1/data/integrity/bcc", # unchanged path, see §2.1
"finance": "/v1/data/integrity/finance",
}
async def evaluate_domains(settings: Settings, opa_input: dict, domain_ids: list[str]) -> OPADecision:
"""
Evaluates the mandatory 'general' bundle plus each resolved domain's
bundle, and combines them: allow only if every bundle allows: violations
from every bundle that denied are concatenated (so a caller sees ALL
reasons, not just the first). requires_baa is OR'd across bundles --
generalizes cleanly since only the healthcare bundle sets it today and
every other bundle's requires_baa defaults False (see schemas note in §2.4).
An unreachable/malformed OPA response for ANY bundle in the list still
raises OPAUnavailableError -- fail-closed is preserved per-bundle, not
weakened by evaluating more than one.
"""
...app/main.py step 5 becomes:
domain_id = resolve_agent_domain(commitment.agent_id, oracle_url=settings.oracle_url)
domains_to_evaluate = ["general"] + ([domain_id] if domain_id else [])
decision = await opa_evaluate_domains(settings, opa_input, domains_to_evaluate)Everything downstream of decision (circuit breaker recording, the BAA gate
keyed on decision.requires_baa, the deny-response shape) is unchanged —
OPADecision doesn't need new fields, only evaluate_domains's combining
logic is new. This is the entire code-path change; nothing about signature
verification, nonce/replay, freshness, BAA, or Merkle batching moves.
Backward compatibility note: until GuardrailRegistry.sol is deployed
and general.rego/finance.rego exist, domains_to_evaluate degenerates to
["general"] (if a general.rego bundle is shipped) plus the healthcare
bundle for healthcare-domain agents — behaviorally identical to today for any
agent whose resolved domain is healthcare, since integrity.bcc is
untouched.
Both follow bcc.rego's exact shape: default allow := false,
allow if count(violation) == 0, violation contains msg if {...}, a
requires_* obligation rule where relevant, and a comment block explaining
the schema constraint they're working under (same discipline bcc.rego's
header uses).
policies/general.rego — the mandatory baseline every agent hits.
Real, evaluable guardrails using only fields every BCCCommitment already
carries (agent_id, intent_type, nonce, timestamp — no domain-specific
fields needed, which is exactly why this is the "floor everyone hits"
bundle):
- Prompt-injection / control-token pattern rejection on
intent_type(a free-text, attacker-controlled field, same threat model asbcc.rego's §3 defense-in-depth regexes) — reject strings that look like an attempt to smuggle a system-prompt override ("ignore previous instructions","disregard all prior",system/assistant-role token spoofing like<|im_start|>). This mirrors industry practice directly: NeMo's jailbreak-detection input rail and Bedrock's "denied topics"/word-filter guardrail both exist to catch exactly this class of string, structurally. - A structural nonce-shape sanity check (
noncemust be a plausible monotonic value, not e.g.0used as a sentinel bypass) as a second, independent layer on top ofnonce_store.py's runtime replay check — same "defense-in-depth over a field we actually have" principlebcc.regoalready documents for its own regex checks.
policies/finance.rego — a new regulated vertical, same shape as HIPAA's.
Given the BCC schema's hash-only design (no raw payload crosses the wire pre-
execution, same constraint bcc.rego's header explains for PHI), a finance
bundle is structurally identical to the HIPAA one: it cannot inspect a real
transaction amount from intended_state_hash alone, so — exactly like
bcc.rego — it splits into (1) structural rules over fields actually present
(intent_type allowlisting for finance-flavored intents, e.g.
TOKEN_TRANSFER, CONTRACT_CALL_PAYMENT) and (2) defense-in-depth regex over
intent_type for sanctioned-address or exfiltration-shaped strings an
attacker might smuggle into the label itself:
package integrity.finance
import rego.v1
default allow := false
allow if { count(violation) == 0 }
finance_intent_types := {"TOKEN_TRANSFER", "CONTRACT_CALL_PAYMENT", "MARKET_ORDER"}
# Placeholder for a real sanctioned-address list -- in production this would
# be an OPA data document synced from an OFAC/Chainalysis feed, same
# "should be a data document, not hand-maintained" note bcc.rego's §1 makes
# for its clinical allowlist.
default _sanctioned_addresses := []
_sanctioned_addresses := data.sanctions.addresses
violation contains msg if {
some addr in _sanctioned_addresses
contains(lower(input.intent_type), lower(addr))
msg := sprintf("FINANCE_SANCTIONS_VIOLATION: intent_type references sanctioned address '%v'", [addr])
}
requires_finance_review if {
input.intent_type in finance_intent_types
}
default requires_finance_review := falserequires_finance_review is the finance-domain analog of bcc.rego's
requires_baa — a signal bcc_middleware could use to gate a future
transaction-amount check the same way requires_baa gates the on-chain BAA
call today (out of scope to build here; flagged as the natural next hook).
| Piece | Stays as-is | Changes |
|---|---|---|
HIPAAGuardrailRegistry.sol |
Unmodified, remains the healthcare domain's anchor | — |
bcc.rego / package integrity.bcc
|
Unmodified — stays the healthcare bundle, keeps its frozen §7 path | Documented (comment) as "the healthcare domain bundle" |
Request lifecycle (main.py steps 0–7) |
Position/order of the OPA check (step 5) unchanged | Step 5 now evaluates a domain-resolved list of bundles instead of one hardcoded path |
OPADecision shape |
{allow, violations, requires_baa} unchanged |
None — requires_baa still just means "healthcare bundle's obligation," generalizable per-bundle without a schema change |
New: GuardrailRegistry.sol
|
— | New singleton, domainId-keyed, mirrors DomainRegistry's hashing + HIPAAGuardrailRegistry's anchor/audit pattern |
New: policies/general.rego, policies/finance.rego
|
— | New domain bundles, same shape as bcc.rego
|
| Domain resolution |
resolve_verification_tier's oracle-resolved, fail-safe pattern |
New sibling resolve_agent_domain, same pattern, using the oracle's already-returned domain_id
|
A new docs/wiki/concepts/multi-domain-guardrails.md (or an extension to
compliance-gate.md) cross-linking: DomainRegistry (existing),
HIPAAGuardrailRegistry (existing, now "the healthcare instance of the
pattern"), this design's GuardrailRegistry (proposed), and bcc.md. Should
note the load-bearing decision in §2.0 (domain must be server-resolved via
the oracle, never client-asserted) since that's the one property most likely
to be gotten wrong by a future implementer under time pressure.
Generated from INTEGRITY-LATEST/docs/wiki. Edit the canonical repository files, not this mirror.
- A2A Negotiation Protocol [PLANNED]
- AIS API — Versioned Wire Spec
- Agent Integrity Score (AIS)
- Agent Primitives (Self-Sovereign Identity)
- Behavioral Commitment Chain (BCC)
- ComplianceGate & Integrity Health
- Cross-Chain Reputation Sync [PLANNED]
- Decentralized Identifier (DID)
- Identity Ceiling & Verification Ladder [BUILT]
- Integrity Market (Prediction Markets, Binary Options, A2A Capital Allocation)
- Integrity Protocol Specification
- Local Metrology (Client-Side AIS Signal Derivation)
- Merkle Batching & Anchoring Convention
- Observability & PHI Safety Pipeline
- On-Chain Governance
- Persistent Memory Bridge
- Persistent Memory, Genesis Root & Lineage [PARTIALLY BUILT]
- Smart BAA (On-Chain Business Associate Agreement Escrow)
- Telemetry Ingestion Pipeline
- Testing Strategy
- The Four Foundational Primitives
- Xibalba Agent Operating Model
- ZK-ML Model-Inference Verification [PLANNED]
- Zero-Knowledge Proving Pipeline