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.

Execution path of one tool call through moritiOne. The agent calls a tool through an interception point, which is the enforcement surface: an MCP proxy, gateway plugin, framework adapter or HTTP service, and the only route the agent is given to the tool. Two. The interception point forwards the call to the tool server, which executes normally. Three. The tool server returns the full structured result, nine fields in the example, to the interception point. Four. The interception point passes the result and the execution context to the mediate function. The context comes from the deployment configuration, which holds the agent, the assigned purpose, the policy, the legal basis and the tool allowlist. The purpose is bound there and is not supplied by the agent. Five, rules propose a decision for each field. Six, guards may restrict those decisions and never loosen them. Seven, the decision is applied to each field. Eight, an audit event is written alongside, with no field values. Nine, only the permitted fields, four of the nine in the example, go back through the interception point to the agent. The other five are withheld and never leave the mediation.AgentInterception pointMCP proxy · plugin · adapter · HTTP servicethe only route the agent is givenTool serverexecutes normally1tool call2tool call3full result9 fields9permitted result4 of 9 fields4result + contextmediated resultmediate(record, context)decides per field what the agent may haveRulespropose a decision5Guardscan only restrict6Enforcementapplied per field7Withheld5 fieldsnever leaveAudit eventno field values8Deployment configagentpurpose ← bound herepolicylegal_basisallowed_tools
The purpose is read from the deployment configuration. The agent does not supply it.

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.

GuardRestricts whenTo
purpose_necessitythe 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 constraineddeny
consentthe legal basis is consent and the purpose is neither in consent_scopes nor a compatible_with purpose of one that isdeny
legal_basisthe policy has require_legal_basis and the request supplies nonedeny
external_transferthe destination is external_model or third_party_api and the field is never_external or tokenise_when_externaldeny or tokenise
retentionthe field is no_persistence and retention is long_term_memory or persistentdeny

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.

excerpt: moriti/policies/popia_hr_v1.yaml
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 lookup

Decision interface

Decision evaluation is deterministic. No LLM participates in mediation.

Inputs

NameMeaning
recordthe structured tool result
agentidentifier of the calling agent
purposethe assigned purpose
destinationwhere released data goes next
retentionhow long it will be kept
legal_basisthe ground for processing
consent_scopespurposes consented to, when supplied
requested_fieldsdefaults to every key in the record

Outputs

NameMeaning
datathe mediated record
withheldfields whose decision releases no usable value
decisionsone per field: decision, reason, matched_rule, restricted_by, obligations
auditthe audit event
python
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 AuditEvent

Enforcement 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
# 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.

ConditionBehaviour
Mediation raises an errorThe 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 failsThe field is replaced with [REDACTED BY PRIVACY POLICY]
detokenise with no reversible tokeniser registeredThe field is redacted
The result is not structured (text that is not JSON)The result is withheld
MCP content that is not textDropped by the proxy
A downstream result type the proxy cannot mediate (elicitation, task)Refused
A tool outside the allowlistNot listed, and calls are refused without reaching the downstream server
A tool definition changes after the proxy pinned itHidden 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.

AuditEvent (abridged)
{
  "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.

SurfaceMechanismStatus
MCPProxy, over stdio or streamable HTTPCovered by tests
IBM ContextForgeExternal plugin (needs Python 3.12)Covered by tests
Google ADKAdapter: plugin callbacksCovered by tests
Strands AgentsAdapter: hooksCovered by tests
Microsoft Agent FrameworkAdapter: function middlewareCovered by tests
Amazon Bedrock AgentCoreAdapter: gateway response interceptorBuilt to the documented contract. Live gateway validation pending
HTTPService interface (moriti-serve) for Azure API Management, Kong and other gateways that can only call HTTPCovered 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.