Building the enforcement point

Agent Control Room can only govern what goes through it.

An action that can run without asking is not governed, however good the rules are. This page describes the side you build: the platform that runs your agents, holds your data and credentials, and acts only on a valid grant.

Everything on How it fits and the plug-in recipes is a placement of this one pattern — in a backend, a gateway, an MCP proxy or an orchestrator.

The two sides

Decision point — Agent Control Room

Holds the rules, the approval queue, the signed grants and the decision record. Answers one step at a time: allow, refuse, or ask a person first.

Enforcement point — your platform

Runs the agent, holds the data and the credentials, and carries out the work. Acts only on a signed, unexpired grant bound to exactly the work in front of it.

Agent proposes a stepYour gateAgent Control Room decidesGate verifies the grantYour gate runs the stepOutcome reported

Five design rules

  1. 1. One gate

    Every privileged step — reading a file, calling a model, writing a record, sending a message, moving money — goes through one wrapper that asks, verifies, acts and reports. There is no second path.

  2. 2. The agent holds no power of its own

    Credentials, tool clients and network access sit behind the gate. The model can propose a step; it can never call the tool directly.

  3. 3. Verify before acting

    Check the signature, expiry, workspace, agent, action and the fingerprint of exactly what is about to run. For a hand-off, check the parent decision too.

  4. 4. Fail closed

    No grant, an expired grant, a fingerprint that does not match or Agent Control Room unreachable: the step does not run.

  5. 5. Close the loop

    Report the outcome after the step. A step held for a person waits and resumes when they answer; it is never retried in a loop.

What can still slip past — and who closes it

These routes are closed by how your platform is built. They are your responsibility, not Agent Control Room's.

Route around the gatePlatform control that closes it
The agent holds an API key or database passwordKeep secrets in the gate's process only; give the agent no environment variables or files containing them.
A tool is registered without the wrapperBuild tool clients only through a factory that requires a verified grant; review new tools in code review.
Side channels: shell, open network, file systemRun agents in a sandbox with no outbound network except the gate; no general-purpose shell.
A step nobody thought to governDefault-deny at the gate: an action without a declared name refuses, and so does an action with no rule.
A grant reused for different workBind each grant to the payload fingerprint and use it once; recompute the fingerprint at the moment of execution.

Incentive-proof by construction

Even an agent with strong reasons to cut corners cannot act without a grant, because the ability to act lives outside the agent. Agent Control Room does not try to read intent or detect deception; it makes the honest path the only path that executes.

A reference pattern

The library curator agent in the Complyee Policy Library follows this pattern. For each document it asks before reading the file, asks again before calling the model, and asks a third time before writing the abstract. Each step carries its own grant bound to its own payload, and a refusal at any step stops the run with a recorded reason.

Code skeletons

TypeScript, fictional names, trimmed to the essentials. Helpers such as acr(), sha256Hex() and signedBytes() are a few lines each; the API reference has the exact formats.

The gate

Ask, verify, act, report.

// One gate for every privileged step. Nothing else holds the credentials.
type Step<T> = {
  agent: string;              // e.g. "promo-writer"
  action: string;             // e.g. "campaign.publish"
  resourceRef?: string;       // e.g. "campaign:spring-2027"
  payload: unknown;           // exactly what is about to run
  parentDecisionId?: string;  // set when this step was handed on
  context?: Record<string, unknown>;
};

export async function withDecision<T>(step: Step<T>, run: () => Promise<T>): Promise<T> {
  const payloadHash = await sha256Hex(canonicalJson(step.payload));
  const request = { ...step, payload: undefined, payloadHash };
  let res = await acr("decideAction", request);

  if (res.decision.effect === "pending") {
    res = await waitForPerson(res.decision.approvalId, request); // poll status, then claim once
  }
  if (res.decision.effect !== "grant") {
    throw new PolicyDenied(res.decision);                 // refused: do nothing
  }
  await verifyGrant(res.decision, { ...step, payloadHash }); // throws on any mismatch

  try {
    const result = await run();
    await acr("reportExecutionOutcome", { decisionId: res.decision.decisionId, outcome: "executed" });
    return result;
  } catch (err) {
    await acr("reportExecutionOutcome", { decisionId: res.decision.decisionId, outcome: "failed" });
    throw err;
  }
}

Checking a grant offline

No call back to Agent Control Room on the hot path.

// Check the grant locally with the published key (fetched once, cached).
async function verifyGrant(d: Grant, expected: Expected) {
  if (Date.parse(d.expiresAt) <= Date.now()) throw new Error("expired");
  if (d.payloadHash !== expected.payloadHash) throw new Error("payload mismatch");
  if (d.agent !== expected.agent || d.action !== expected.action) throw new Error("wrong step");
  if ((d.parentDecisionId ?? "") !== (expected.parentDecisionId ?? "")) throw new Error("wrong lineage");

  const key = await publicKeyFor(d.keyId);   // from getGrantVerificationKey
  const ok = await crypto.subtle.verify("Ed25519", key, b64(d.publicSignature), signedBytes(d));
  if (!ok) throw new Error("bad signature");
}
// For high-value steps, also call verifyDecision to catch a grant revoked before expiry.

When a person must decide

The exception path.

// A person is asked only where a rule says so. Park the step and resume.
// Poll getApprovalStatus (never decideAction) while pending; once granted,
// repeat the identical decideAction call once to claim the signed, single-use grant.
async function waitForPerson(approvalId: string, request: DecideRequest) {
  for (let delay = 5_000; ; delay = Math.min(delay * 2, 15_000)) {
    await sleep(delay);
    const s = await acr("getApprovalStatus", { approvalId });
    if (s.status === "pending") continue;
    if (s.status === "granted") return acr("decideAction", request); // the claim
    return { decision: { effect: "reject", reason: s.status } };      // refused, expired or consumed
  }
}
// Claim within fifteen minutes of approval, and never change the payload while waiting.

Handing work to another agent

Authority narrows on every hop.

// Handing work to another agent: pass the parent decision, never a credential.
await withDecision(
  { agent: "lead-qualifier", action: "crm.update", payload: lead,
    parentDecisionId: parentGrant.decisionId },
  () => crm.update(lead),
);
// ACR narrows authority on every hop: depth, expiry and amounts can only shrink.

decideActiongetApprovalStatusgetGrantVerificationKeyverifyDecisionreportExecutionOutcome

Before you go live

  • Every privileged step goes through one gate.
  • The agent process holds no credentials, keys or tokens.
  • Tool clients can only be built with a verified grant.
  • Agents run without open network or shell access.
  • The gate refuses when Agent Control Room cannot be reached.
  • Grants are checked for signature, expiry, agent, action and payload fingerprint.
  • Each grant is used once, for the work it was issued for.
  • Hand-offs pass the parent decision, never a credential.
  • Held steps wait for a person and resume; nothing retries in a loop.
  • Every executed or failed step is reported back.