Concept

HITL approval envelopes.

LLM proposes. Human confirms — via a different actor identity. SoD enforced server-side. The LLM cannot impersonate a human, period.

Human-in-the-loop (HITL) is the routing decision the gate makes when the action requires human approval — T2 (reversible above materiality or sensitive account) or T3 (irreversible, requires dual HITL). The envelope is closegate's name for the data structure that travels from proposal to approval to settlement.

The lifecycle

  1. LLM proposes. An MCP tool call comes in with a T2 or T3 tier. The actor is llm:<session-id>.
  2. Gate decides. evaluate() returns RequireHumanApproval(clause). The runtime writes a PENDING escalation row with the verbatim clause text.
  3. Notifier dispatches. The escalation lands in the web inbox + a Slack Block Kit card / Teams Adaptive Card 1.5 with a deeplink to the canonical web record.
  4. Human confirms. A named approver (different actor identity than the proposer) clicks confirm. Their actor identity is set by the OIDC token; the LLM cannot fake it.
  5. Gate re-evaluates. The confirmation pass through the gate again with the new actor. SoD check fires: proposed_by != actor.id. If valid, the gate allows; the state machine transitions; an OK audit row lands.

SoD enforced server-side

Segregation of duties (SoD) is enforced where the request lands, not where the prompt suggests. Three concrete checks:

  • Same-actor confirm: proposed_by == actor.idDeny(SOD_SAME_ACTOR).
  • LLM-impersonating-human: source == llm AND actor.kind == llm on a confirm action → Deny(SOD_SAME_ACTOR) (defense-in-depth).
  • Dual-HITL T3: payment-run submission requires requestor ≠ approver ≠ payer — three distinct actor identities, three distinct humans, full chain.

The MCP transport never accepts actor_id as a tool parameter. There is no API surface where the LLM can claim to be a different actor.

The approval surface

Three channels, all referencing the same canonical record:

Web inbox

The Vue 3 workspace renders /inbox/escalations with the verbatim policy clause text, the proposing actor, the materiality + sensitive-account context, and the diff drawer showing the proposed state change. Approvers confirm, reject, or withdraw from this surface.

Slack (Block Kit)

Set SLACK_WEBHOOK_URL. Approval envelopes appear as Block Kit cards with named approvers tagged, a deeplink back to the web record, and inline confirm/reject buttons (button confirms still flow through OIDC, so SoD remains intact).

Microsoft Teams (Adaptive Card 1.5)

Set TEAMS_WEBHOOK_URL. Same shape as Slack — Adaptive Card 1.5 with a deeplink, named approvers, and confirm/reject actions that route back through the gate.

Escalation management

From the MCP surface (your AI agent can use these freely — all T0/T1):

  • escalate_match (T1): escalate a proposal to a named (or any) manager. Metadata only — the resolution still goes through the gate.
  • list_pending_escalations (T0): list PENDING escalations, optionally filtered by named approver.
  • resolve_escalation (T1): resolve a PENDING escalation as RESOLVED_CONFIRM / RESOLVED_REJECT / WITHDRAWN. SoD: only the named approver (or originator on withdraw) may act.

Aged-exception sweeper

Escalations that sit beyond a configurable threshold get swept by the background workers/aged_exception_sweeper.py — either auto-escalated to the next-level approver, or surfaced in the controller's daily digest. Adopters configure the threshold in policy.yaml.

Adjacent reading

Inbound

Talk to the maintainer

Two design-partner slots open this quarter. One real workflow, your real policy.yaml, monthly 30-min call, direct line. Apache-2.0, self-hosted, no seat licensing — forever.