Chapter 29 — Human-in-the-Loop Systems
Resolution proposes a $75 refund on O-1001 — above Northline’s automatic threshold. Policy returns amount_hitl. Someone pings Maya on Slack with a screenshot of the draft. She approves in chat. Outreach sends. Nothing durable links her “yes” to the send capability or records what she saw.
Policy escalate → checkpoint (awaiting_approval) → evidence packet → Maya decides → mint outreach:send → resume → send audited
Without HITL as architecture, approvals evaporate, timeouts default unsafe, and send window / required fields checks have no human gate when the rule pack demands one.
This chapter adds review as FSM state: evidence packets, bounded decisions, safe timeouts, and scoped delegation — the case resumes from a checkpoint, not from chat memory.
First principles
- HITL is a state in the FSM, not an informal message (Ch 7–8).
- Reviewers decide from evidence packets, not from model vibes.
- Timeouts must default safe for regulated side effects — usually deny/no-op.
- Accountability is data: who approved, what they saw, what changed.
- Delegation is scoped. A reviewer may approve send for one case/action, not mint global god-mode.
- The model may recommend; it may not self-approve.
ShopOps escalation criteria include an amount threshold, a NEEDS_HUMAN or GRAY policy result, missing order facts, customer dispute signals, and authorization gaps the orchestrator will not mint automatically.
Concrete example
Resolution proposes exception-path email with ``$75refund. Policy pack returns deny withamount_hitl`. Orchestrator:
- Checkpoint case (
awaiting_approval). - Enqueue approval with evidence: intake summary, proposal, policy reasons, draft body, required-fields checklist.
- SLA timer: 24h.
- Reviewer approves with optional edit (Ch 40).
- Capability
outreach:sendminted; worker resumes; send audited.
If the timer fires first → auto-reject, ledger event approval_timeout, no send.
Diagram — HITL sequence
sequenceDiagram
participant W as Worker
participant Q as ApprovalQueue
participant H as Human reviewer
participant C as Checkpoint
W->>C: park awaiting_approval
W->>Q: enqueue evidence packet
alt approve
H->>Q: approve (+ optional edit)
Q->>W: resume signal
W->>C: load checkpoint
W->>W: mint send scope + execute
else timeout / reject
Q->>C: safe default (no send)
end
running ──► threshold/deny ──► awaiting_approval ──┬── approve ──► running
├── modify ──► running
└── timeout ─► failed_safe
Caption: Notice resume always goes through checkpoint + queue records, not through chat memory.
Implementation — approval queue
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Any
import uuid
class ApprovalStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
TIMEOUT = "timeout"
MODIFIED = "modified"
@dataclass
class EvidencePacket:
case_id: str
tenant_id: str
action: str
summary: str
draft_body: str | None
policy_reasons: tuple[str, ...]
evidence_ids: tuple[str, ...]
state_hash: str
@dataclass
class ApprovalRequest:
id: str
packet: EvidencePacket
status: ApprovalStatus = ApprovalStatus.PENDING
reviewer_id: str | None = None
decision_note: str | None = None
modified_body: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc) + timedelta(hours=24))
@dataclass
class ApprovalQueue:
_items: dict[str, ApprovalRequest] = field(default_factory=dict)
def enqueue(self, packet: EvidencePacket, ttl: timedelta = timedelta(hours=24)) -> ApprovalRequest:
req = ApprovalRequest(
id=str(uuid.uuid4()),
packet=packet,
expires_at=datetime.now(timezone.utc) + ttl,
)
self._items[req.id] = req
return req
def decide(
self,
approval_id: str,
*,
reviewer_id: str,
approve: bool,
note: str = "",
modified_body: str | None = None,
) -> ApprovalRequest:
req = self._items[approval_id]
self.expire_due()
if req.status != ApprovalStatus.PENDING:
raise RuntimeError(f"not pending: {req.status}")
req.reviewer_id = reviewer_id
req.decision_note = note
if not approve:
req.status = ApprovalStatus.REJECTED
elif modified_body is not None:
req.status = ApprovalStatus.MODIFIED
req.modified_body = modified_body
else:
req.status = ApprovalStatus.APPROVED
return req
def expire_due(self, now: datetime | None = None) -> list[str]:
now = now or datetime.now(timezone.utc)
timed_out: list[str] = []
for req in self._items.values():
if req.status == ApprovalStatus.PENDING and req.expires_at <= now:
req.status = ApprovalStatus.TIMEOUT
timed_out.append(req.id)
return timed_out
def safe_to_send(self, approval_id: str) -> bool:
req = self._items[approval_id]
return req.status in (ApprovalStatus.APPROVED, ApprovalStatus.MODIFIED)
Wire to checkpoints: on park, persist approval_id in case state; on worker tick, if safe_to_send, continue; if TIMEOUT/REJECTED, transition to failed_safe / replan.
Failure modes
| Failure | Effect | Fix |
|---|---|---|
| Screenshot HITL | No replayable evidence | Structured packets |
| Timeout → send | Regulated incident | Safe default deny |
| Model self-approve | Authz bypass | Only queue API sets status |
| Stale resume | Double send | Idempotency keys + lease (Ch 32) |
| Reviewer overload | Rubber stamps | Thresholds + batch UX (Ch 40) |
| Missing accountability | Audit gap | Reviewer id + packet hash |
Production considerations
- SLA metrics: time-to-decision, timeout rate, modify rate, override reasons.
- UI shows diff between model draft and policy constraints (Ch 40).
- Bind approvals to
case_id+ action + body hash so edits invalidate old tickets. - Train reviewers that “approve” is a legal/control act, not a chat reaction.
- Load-test the queue; HITL is often the bottleneck, not the model.
Chapter summary
- Humans are part of the control loop, with states and timeouts.
- Evidence packets make decisions reviewable and auditable.
- Safe defaults on timeout for consequential actions.
- Approvals mint scoped capabilities; models do not.
- Checkpoint park/resume is the durability mechanism.
- Accountability fields are mandatory, not optional metadata.
- Escalation criteria should be explicit product rules.
- HITL UX is a product surface (expanded in Ch 40).
Exercises
- Mechanical. Enqueue an approval; expire it; assert
safe_to_sendis False and status isTIMEOUT. - Integration. Park a checkpoint, approve, resume — assert send happens once with idempotency key.
- Design. Define escalation criteria for voice contact vs email; which fields must appear in the packet?
References
- Human factors / approval workflow patterns in safety-critical systems. [VERIFY]
- Durable execution / pause-resume concepts (Temporal docs — conceptual). [VERIFY]
- Chapters 7–8, 28, 30, 40