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
- LLM proposes. An MCP tool call comes in with a T2 or T3 tier. The actor is
llm:<session-id>. - Gate decides.
evaluate()returnsRequireHumanApproval(clause). The runtime writes aPENDINGescalation row with the verbatim clause text. - 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.
- 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.
- 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.id→Deny(SOD_SAME_ACTOR). - LLM-impersonating-human:
source == llm AND actor.kind == llmon 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
- The policy gate — what produces the HITL envelope
- The audit log — what records the resolution
- For architects — the OIDC + reverse-proxy auth flow