Architecture
moriti sits between an agent and its tools. Every tool result passes through a step called mediation before the agent sees it. For each field, moriti evaluates the deployment-bound purpose and policy to decide whether it may reach the agent. Permitted fields go through, the rest are withheld, and an audit event records why without recording the data.
For example: an HR assistant asks for an employee record. The tool returns nine fields. The agent receives four.
Policy evaluation
Rules produce candidate decisions. Guards are the checks that run afterwards, and they may restrict those decisions. A guard cannot make a result more permissive. Rules propose, guards dispose. A rule may allow salary for payroll, and a guard still blocks it when the data is headed to an external model.
For each requested field the engine takes the most specific matching rule, with ties going to the rule declared first. If no rule matches, default_decision applies, which is deny unless the policy says otherwise. Guards then run in the order below.
Decisions have a defined order of restrictiveness: allow → tokenise → mask → redact or filter → require_approval → deny. A guard may only move a decision towards the more restrictive end of that ordering. What each does: allow releases the value, tokenise replaces it with an opaque token, mask hides part of it, redact replaces it with a placeholder, filter drops it, require_approval holds it and deny removes it.
| Guard | Restricts when | To |
|---|---|---|
purpose_necessity | the field is not in the assigned purpose's necessary_fields, or the policy does not define the purpose and require_known_purpose is on, which is the default. A defined purpose with no listed fields is not constrained | deny |
consent | the legal basis is consent and the purpose is neither in consent_scopes nor a compatible_with purpose of one that is | deny |
legal_basis | the policy has require_legal_basis and the request supplies none | deny |
external_transfer | the destination is external_model or third_party_api and the field is never_external or tokenise_when_external | deny or tokenise |
retention | the field is no_persistence and retention is long_term_memory or persistent | deny |
Otherwise, a no_persistence field, or any field sent to agent_memory, is released with obligations, which are conditions that travel with the value: no_long_term_memory and delete_after_task.
name: popia_hr
default_decision: deny # no rule matched: deny
require_legal_basis: true # legal_basis guard: no basis, no release
require_known_purpose: true # an undefined purpose is denied (the default)
purposes:
employee_directory_lookup: # an assigned purpose
necessary_fields: [name, email, department, job_title] # purpose_necessity guard
fields:
salary:
never_external: true # external_transfer guard: never leaves
no_persistence: true # retention guard: never kept as memory
bank_account:
tokenise_when_external: true # external_transfer guard: token only
no_persistence: true
rules:
- id: directory-contact-fields
match: { purpose: employee_directory_lookup }
field: [name, email, department, job_title]
decision: allow
reason: contact details are necessary for a directory lookupDecision interface
Decision evaluation is deterministic. No LLM participates in mediation.
Inputs
| Name | Meaning |
|---|---|
record | the structured tool result |
agent | identifier of the calling agent |
purpose | the assigned purpose |
destination | where released data goes next |
retention | how long it will be kept |
legal_basis | the ground for processing |
consent_scopes | purposes consented to, when supplied |
requested_fields | defaults to every key in the record |
Outputs
| Name | Meaning |
|---|---|
data | the mediated record |
withheld | fields whose decision releases no usable value |
decisions | one per field: decision, reason, matched_rule, restricted_by, obligations |
audit | the audit event |
from moriti import PrivacyPlane, RequestContext, Destination, LegalBasis
plane = PrivacyPlane.from_policy("popia_hr_v1")
result = plane.mediate(
{"name": "Sarah Ndlovu", "email": "sarah@example.co.za", "salary": 1450000},
RequestContext(
agent="hr_assistant",
purpose="employee_directory_lookup",
destination=Destination.INTERNAL_MODEL,
legal_basis=LegalBasis.LEGITIMATE_INTEREST,
requested_fields=["name", "email", "salary"],
),
)
result.data # {'name': 'Sarah Ndlovu', 'email': 'sarah@example.co.za'}
result.withheld # ['salary']
result.decisions # one FieldDecision per field
result.audit # the AuditEventEnforcement path
An engine an agent must volunteer to call is not enforcement.
For enforcement, moriti must be the only route the agent is given to the tool. Depending on the integration, that is an MCP proxy, a gateway plugin, a framework adapter or an HTTP mediation service. The proxy is an MCP server to the agent and an MCP client to the real server, so the downstream address never appears in the agent's configuration.
Purpose is bound at deployment: the agent does not get to choose why it is using the data. It is read from deployment configuration and is never supplied by the agent at runtime. The only value taken from tool arguments is a best-effort data-subject reference, which appears in the audit event and does not take part in any rule.
# hr_assistant.yaml: read by the proxy or adapter at startup
agent: hr_assistant
purpose: employee_directory_lookup # bound when the agent is provisioned
policy: popia_hr_v1
legal_basis: legitimate_interest
destination: internal_model
retention: ephemeral
allowed_tools: [get_employee, list_employees]
downstream:
command: python3
args: [-m, hr_system.mcp_server]Fail-closed behaviour
Fail closed means that if moriti cannot safely decide, it withholds the data. The agent receives a refusal, a placeholder or nothing at all, and the original result is never passed through.
| Condition | Behaviour |
|---|---|
| Mediation raises an error | The proxy and the adapters return a refusal. The HTTP service returns HTTP 200 with a refusal in result and error: mediation_failed, so a gateway policy is not tempted to pass the original through |
| Enforcing a decision on a field fails | The field is replaced with [REDACTED BY PRIVACY POLICY] |
detokenise with no reversible tokeniser registered | The field is redacted |
| The result is not structured (text that is not JSON) | The result is withheld |
| MCP content that is not text | Dropped by the proxy |
| A downstream result type the proxy cannot mediate (elicitation, task) | Refused |
| A tool outside the allowlist | Not listed, and calls are refused without reaching the downstream server |
| A tool definition changes after the proxy pinned it | Hidden from the tool list, and calls are refused |
Tool integrity
Tool-definition pinning means moriti remembers what each tool looked like at startup and refuses the tool if that changes.
At startup the MCP proxy records a SHA-256 hash of each downstream tool's description and schema. If a tool's definition later differs from its pin, the proxy hides the tool from tools/list and refuses calls to it.
This detects tool-definition changes during the lifetime of the proxy process. Restarting the proxy establishes a new baseline from the definitions reported at startup.
This helps detect the class of tool-definition change sometimes described as an MCP rug pull.
tools/list is also filtered against the deployment allowlist.
Audit events
Each mediated call emits an audit event containing, for each field, the decision, the reason, the rule that proposed it and the guards that restricted it, with any obligations. Protected field values are not written to the audit event. By default the event goes to the application log. moriti.audit.set_sink sends it to a store or SIEM instead. Audit delivery sits outside the enforcement boundary. If a custom sink fails, mediation still completes and the event falls back to the application log.
{
"event_id": "…",
"timestamp": "2026-10-03T10:24:12Z",
"agent": "hr_assistant",
"purpose": "employee_directory_lookup",
"tool": "get_employee",
"destination": "internal_model",
"retention": "ephemeral",
"legal_basis": "legitimate_interest",
"policy": "popia_hr_v1",
"fields_requested": ["name", "salary"],
"decisions": { "name": "allow", "salary": "deny" },
"reasons": { "name": "contact details are necessary for a directory lookup", "salary": "…" },
"matched_rule": { "name": "directory-contact-fields" },
"restricted_by": { "salary": ["purpose_necessity"] }
}Integration surfaces
The runtime is shared; the proxy, plugins, adapters and HTTP service are different enforcement surfaces.
| Surface | Mechanism | Status |
|---|---|---|
| MCP | Proxy, over stdio or streamable HTTP | Covered by tests |
| IBM ContextForge | External plugin (needs Python 3.12) | Covered by tests |
| Google ADK | Adapter: plugin callbacks | Covered by tests |
| Strands Agents | Adapter: hooks | Covered by tests |
| Microsoft Agent Framework | Adapter: function middleware | Covered by tests |
| Amazon Bedrock AgentCore | Adapter: gateway response interceptor | Built to the documented contract. Live gateway validation pending |
| HTTP | Service interface (moriti-serve) for Azure API Management, Kong and other gateways that can only call HTTP | Covered by tests |
Known limits
- Only structured JSON is mediated field by field. Text that is not JSON is withheld.
- A nested object is decided as one field. Its inner keys are not evaluated separately.
- Consent is passed in as request context. moriti is not a consent-management system.
- The default tokeniser is one-way. A vault or reversible tokeniser can be supplied through
set_tokeniser. - There is no built-in durable audit store. Events go to the application log unless you register a sink.
- AgentCore has not been validated against a live gateway.
- The runtime does not ship with a field-encryption or key-management layer.