Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. HITL is a state in the FSM, not an informal message (Ch 7–8).
  2. Reviewers decide from evidence packets, not from model vibes.
  3. Timeouts must default safe for regulated side effects — usually deny/no-op.
  4. Accountability is data: who approved, what they saw, what changed.
  5. Delegation is scoped. A reviewer may approve send for one case/action, not mint global god-mode.
  6. 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:

  1. Checkpoint case (awaiting_approval).
  2. Enqueue approval with evidence: intake summary, proposal, policy reasons, draft body, required-fields checklist.
  3. SLA timer: 24h.
  4. Reviewer approves with optional edit (Ch 40).
  5. Capability outreach:send minted; 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

FailureEffectFix
Screenshot HITLNo replayable evidenceStructured packets
Timeout → sendRegulated incidentSafe default deny
Model self-approveAuthz bypassOnly queue API sets status
Stale resumeDouble sendIdempotency keys + lease (Ch 32)
Reviewer overloadRubber stampsThresholds + batch UX (Ch 40)
Missing accountabilityAudit gapReviewer 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

  1. Mechanical. Enqueue an approval; expire it; assert safe_to_send is False and status is TIMEOUT.
  2. Integration. Park a checkpoint, approve, resume — assert send happens once with idempotency key.
  3. 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