Agents Honestly
Part XXI · Pattern CatalogSecurity Patterns

Dual Control

High-blast-radius actions require a second, human signature.

Exercise

Problem

A single approval gate is one person's judgment standing between an agent and an irreversible action.

That is enough for a $200 credit. It is not enough for a $200,000 one, or for a bulk operation touching four hundred accounts, or for anything a regulator will later ask about, because the failure mode of a single approver is not that they are malicious. It is that they are the three-hundredth approval of the morning.

The documented pattern is a reviewer averaging eleven seconds per decision. Add an adversary who can flood the queue with benign requests until rubber-stamping is the established habit, and the gate you built for safety is a control that has quietly stopped functioning while everyone believes oversight exists.

There is also a case single approval cannot address at all: the approver is the problem. An insider who can both initiate and approve has no boundary in front of them.

Forces

  • Attention is finite, and every approval spends some. Two signatures cost twice.
  • Fatigue is an attack vector, not just a UX complaint: flooding the queue is a documented technique.
  • Some actions are genuinely irreversible and expensive enough to justify the cost.
  • Two people who see the same summary are not independent: they are one judgment, twice.
  • Availability drops. Two approvers is a strictly harder scheduling problem, especially at 3am.
  • Separation of duties is often a compliance requirement, not an engineering preference.

Solution

Require two distinct human signatures from different principals, on the same recorded request, before the effect executes.

   agent proposes  ──▶  request recorded (immutable)

                ┌─────────────┴─────────────┐
                ▼                           ▼
        APPROVER 1                     APPROVER 2
        sees: the agent's card         sees: the card AND
        (what it proposes, why)        the SOURCE data, and
                                       that approver 1 signed
                │                           │
                ├── must be a different ────┤
                │   principal than          │
                │   the initiator and       │
                │   each other              │
                └─────────────┬─────────────┘

                      effect executes ONCE
                      both signatures in the audit record
Two signatures, two constraints. Different principals, and, the part that matters, different information.

Four rules:

Enforce distinctness in code, on principals. Not on display names, not on "please have someone else check." The dispatcher rejects a second signature from the same principal that initiated or first approved, and the check runs on the delegated identity rather than on anything the client sends.

Give the second approver different information. This is the rule that makes dual control worth its cost. A reviewer reading the same summary as the first is a second vote, not a check, the same failure as a verifier reading another agent's conclusion. The second approver should see the underlying records, not the agent's rendering of them.

Record both rendered cards, as bytes. Re-rendering later produces a different card because balances settle and documents get superseded. What each approver actually saw is the artifact that demonstrates the decisions were informed.

Keep the population small and the trigger rare. Dual control that fires often becomes two rubber stamps instead of one, which is worse than a single gate because it costs twice as much attention to produce the same non-review.

Code

ts/src/security/dual-control.ts
export interface Signature {
  principal: string;     // from the delegated token, never from the client
  at: string;
  cardRef: string;       // the rendered bytes THIS approver saw
  view: 'summary' | 'source';
}

export interface DualRequest {
  requestId: string;
  initiatedBy: string;   // the run's principal — cannot also approve
  signatures: Signature[];
}

export function admitSignature(
  req: DualRequest, sig: Signature,
): { ok: true } | { ok: false; reason: string } {
  // Separation of duties, enforced on principals rather than on trust.
  if (sig.principal === req.initiatedBy) {
    return { ok: false, reason: 'initiator may not approve' };
  }
  if (req.signatures.some(s => s.principal === sig.principal)) {
    return { ok: false, reason: 'this principal has already signed' };
  }
  // The second approver must have looked at the source, not the summary.
  // Two people reading the same card are one judgement, twice.
  if (req.signatures.length === 1 && sig.view !== 'source') {
    return { ok: false, reason: 'second approval requires the source view' };
  }
  return { ok: true };
}

export const isAuthorized = (req: DualRequest) => req.signatures.length >= 2;

// The effect runs ONCE, after the second signature, under the run's
// idempotency key — a re-signed request must not issue a second credit.

The view: 'source' requirement is the design decision that separates this pattern from "ask two people." It is enforceable because the UI knows which view was rendered, and it is what stops the second signature from being a formality.

Trade-offs

Latency, roughly doubled and often worse. Two people must be available, and the second may only start after the first finishes. On an SLA-bound path this can consume the whole window, which is why the trigger has to be rare.

Availability becomes a scheduling problem. Nights, weekends, and holidays need a defined answer: a named backup, a documented break-glass with heightened logging, or an honest "this waits until Monday." Undefined is the version that produces a 2am incident with no one to call.

It does not stop collusion or a shared misconception. Two people who both trust the agent's summary because it looks authoritative will both approve. The source-view requirement is the mitigation; nothing eliminates it.

Twice the fatigue budget. Every dual-control gate spends two withdrawals from the same finite account. Firing it on routine work is the fastest way to make both signatures meaningless.

When not to use it

Below tier 3. The tier matrix reserves two signatures for irreversible-and-wide, and everything else is a single gate or autonomous. Widening it is the most common way this pattern is misapplied.

When you cannot staff it. A second approver who does not exist is a run that hangs. If there is no second population, the honest control is a lower autonomous cap plus stronger detection, not a gate nobody can service.

When a deterministic check would do. "Two people must agree the amount is under the contract limit" is a predicate. Reserve human signatures for judgment.

As compensation for a missing boundary. Dual control on top of an agent with unbounded argument scope is asking two humans to be the authorization layer, at machine speed. Fix the scope first; the gate is for what remains.

The second signature is only worth anything if it sees something different

This is the point on which real dual control and ceremonial dual control diverge, and the shape of the argument recurs throughout this book.

A verifier reading another agent's conclusion is a second vote. A grounding check against a summary is not a check. A second approver reading the first approver's card is the same failure with people in it. Correlated errors do not cancel, and two correlated judgments are worth barely more than one.

So the design requirement is not "another human." It is another view: the source records rather than the agent's rendering, and ideally a different role with a different reason to be skeptical. If you cannot arrange that, be honest that you have bought accountability rather than assurance, which is still worth something, and is not what dual control claims to provide.

On this page