Skip to content
Documentation menu

Documentation

Concepts and model

MakerChecker models an agent the way a regulated firm models an employee: an identity that holds one role, a key ring of versioned skill grants, and hard limits on its authority. Every action runs through one authorization check before it happens. This page is the developer-depth account of the entities, the check, and the codes it returns.

Companion reading

For the plain-language version of these ideas, written for a non-engineer, read the plain-language primitives. This page is the technical model the same primitives compile down to.

The model: agent, role, skill, grant

Four entities carry the structure. An agent holds exactly one role. A role is granted specific skill versions. Everything not granted is denied. Nothing is edited in place: skills, grants, and roles are versioned, so the record of what was allowed at a point in time stays true.

EntityShapeRule it enforces
agentA name, a status (active, suspended, retired), a model config, and exactly one role.One identity, one role. A suspended or retired agent acts on nothing.
roleA name and a limits JSONB object. Skills are granted to roles, never to agents directly.Authority is a property of the role, so it is reviewable and shared.
skillA canonical name@version (for example txn-match@1), a risk tier, a status (published or deprecated), and input and output schemas.A capability is addressable, typed, and pinned to an exact version.
role_skill_grantA versioned, revocable link between one role and one skill version.Deny by default. No unrevoked grant means no execution, with no bypass.

A fifth entity, sod_constraint, is a symmetric pair of roles that cannot both act in the same run or proxy session. It is stored once with role_a_id < role_b_id so the pair has a single canonical form. Segregation of duties is covered in its own section below.

Skills and risk tiers

A skill is the unit an agent proposes to run. It is addressed by its canonical name@version, carries input and output schemas, and is either published or deprecated. Deprecating a version stops new executions without rewriting history: the audit of what ran under the old version stays intact.

Every skill has a risk tier that decides what happens after the authorization checks pass:

  • low and medium run immediately once the checks pass, and the outcome is recorded.
  • high stops. In a flow it requires a preceding approval gate. Through the proxy it is categorically denied, because the proxy path has no gates.

The risk tier is read from the skill itself, so the same skill is governed the same way wherever it is called. A high-risk skill cannot quietly become low-risk by being invoked from a different place.

Grants: deny by default, versioned

A grant ties one skill version to one role. The check looks for an exact, unrevoked grant for the agent's role and the specific version proposed. There is no inheritance and no wildcard: a grant of txn-match@1 does not cover txn-match@2.

Grants are revocable, and revocation is a timestamp rather than a delete. A revoked grant still exists in the record; it simply no longer authorizes anything. This is why the absence of a grant, and the presence of a revoked one, are both denials with the same outcome: the skill does not run.

server/src/engine/enforcement.ts

enforce()

// enforce() runs before any action, at both decision time and
// invocation time. Deny by default, in this order:
//
// 1. agent exists and is active
// 2. each skill@version exists and is published
// 3. an unrevoked grant ties that skill to the agent's role
// 4. high-risk skills require a preceding approval gate
// 5. segregation of duties: no conflicting role already acted
//
// Any failed check throws an EnforcementError with a code. The
// action never runs, and the denial is appended to the audit chain.

The same check runs twice in a flow: once at decision time, when the step is scheduled, and again at invocation time, immediately before the skill executes. The proxy runs the identical agent, skill, grant, and segregation-of-duties checks before letting an external orchestrator run the tool. Deny by default, on both paths.

Segregation of duties

A segregation-of-duties constraint names two roles that must not both act in the same context. The context is a flow run or a proxy session. When a role acts, the check records a frozen snapshot of which role it was. The next action checks that snapshot set: if any role that already acted forms an active constraint pair with the candidate role, the action is denied with sod_violation.

The core rule

If a role already acted in a run or session, any role paired with it by an active constraint cannot act in that same run or session. The role each action ran under is frozen per step, so reassigning an agent's role mid-run cannot rewrite who already acted. The separation holds structurally, not by procedure.

Two details make this hold under pressure. Denied attempts never join the actor set: an action that was blocked did not act, so it cannot be used to provoke a later violation or to consume a slot. And on the proxy, concurrent checks in one session serialize on the session row, so two calls cannot race past the actor set at the same instant.

Approval gates

An approval gate is a first-class workflow step, not a wrapper around a skill. It requires a quorum: n of m named approvers must sign before the flow advances. The gate is where a high-risk step waits for human sign-off, and where that sign-off is recorded as part of the run.

  • Quorum. The gate defines who may approve and how many of them are required. Fewer than the quorum, and the flow does not advance.
  • No self-approval. The requester can never approve their own step. forbid_requester defaults to true, so the agent or person that proposed the action is excluded from the quorum by default.
  • Fails closed. If the gate cannot be satisfied, the flow stops at it. Nothing downstream runs on an unmet approval.

Gates exist only on the flow path. A high-risk skill called through the proxy has no gate to wait at, so the proxy denies it outright and tells the caller to run it in a governed flow instead.

Role limits

A role's limits field is a JSONB contract, checked immediately before every skill invocation. Limits are split into per-skill caps and per-run budgets. The limits enforced against a scheduled step are the frozen copy taken when the step was scheduled, so an admin editing the live role mid-run cannot change what an already-scheduled run enforces.

JSONB

roles.limits

{
"skills": {
"txn-match@1": {
"maxInvocationsPerRun": 50,
"maxAmountPerInvocation": 10000,
"amountField": "amount",
"allowlist": { "field": "destination", "values": ["acct-001", "acct-002"] },
"pathScope": { "field": "path", "prefix": "/data/recon" }
}
},
"run": {
"maxSkillInvocations": 200,
"maxTokens": 100000
}
}

Per-skill caps gate the arguments of the call, not just which skill may run:

  • maxInvocationsPerRun: how many times this skill may run in the scope (a flow run or a proxy session).
  • maxAmountPerInvocation with amountField: the numeric value in that field may not exceed the cap, and a negative amount is rejected.
  • allowlist (field, values): the value in that field must be one of the listed strings. Used for a recipient or destination allowlist.
  • pathScope (field, prefix): the path in that field must sit under the prefix, with traversal out of it refused.

Per-run budgets cap the whole run: maxSkillInvocations across all skills, and maxTokens across all model calls. Both are counted conservatively from the audit trail, so every attempt, including errors, counts toward the cap.

Unreadable config fails closed

Limits fail closed. A configured amount, allowlist, or path limit whose input field is missing or the wrong type denies the call. An unreadable limit value denies everything it governs. Ambiguity is a denial, never a pass.

Denial-code reference

Every denial returns a stable code, so a caller can branch on it and a reviewer can read the audit without guessing. Authorization codes come from the enforcement check; limit codes come from the role-limits contract. Per-skill limit checks also emit an unreadable-config variant when a configured amount, allowlist, or path field is missing or the wrong type (limit_amount_unreadable, limit_allowlist_unreadable, limit_path_unreadable). The proxy enforces the authorization and per-skill codes; the run-level budgets, limit_tokens and limit_run_invocations, are enforced on the flow path.

CodeMeaning
agent_not_foundNo agent exists with that name.
agent_not_activeThe agent is suspended or retired, not active.
skill_not_foundNo skill exists at that name@version, or the reference is not canonical.
skill_deprecatedThe skill version exists but is no longer published.
skill_not_grantedNo unrevoked grant ties that skill version to the agent's role.
high_risk_requires_gateA high-risk skill ran without a preceding gate, or was called through the proxy, which has none.
sod_violationA role paired by an active constraint already acted in this run or session.
limit_invocationsThis skill reached its per-scope invocation cap.
limit_amountThe amount field exceeds maxAmountPerInvocation, or is negative.
limit_tokensThe run reached its token budget.
limit_run_invocationsThe run reached its total skill-invocation budget.
limit_allowlistThe field value is not on the configured allowlist.
limit_pathThe path field is outside the allowed prefix.

With the model in hand, the next step is to put it in front of an agent you already have. Wrap your agent covers the LangChain connector, the Claude Agent SDK, the generic wrapper, and Python. To see the chain these checks write, read the audit trail.

Stuck or evaluating for a regulated team?Book a walkthroughOpen an issue on GitHub