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 40 — Human Review and Case Management

O-1001 needs a reship email. Policy passed. Outreach drafted. Maya softens one sentence and hits modify.

The worker resumes and sends the model’s original draft anyway. Jordan gets the line Maya removed. You built a theatre desk, not human review.

The case desk is part of the product. Reviewers get a read API for the evidence packet — proposal, policy reasons, required-fields checklist, contact history — and actions to approve, reject, or modify. Every decision lands in the audit trail and patches the checkpoint so the worker sends the bytes that were actually approved.

worker parks on approval → Maya reads packet → approve | reject | modify → checkpoint + audit → worker resumes → Outreach sends approved body

Without checkpoint-backed modify, HITL is cosmetic. Without a read API, reviewers decide from model prose alone. Without reviewer_id on every decision, accountability vanishes when something goes wrong.

This chapter adds the review APIs and checkpoint patch that bind human authority to what Outreach actually sends.

First principles

  1. Review UX is a first-class API, not a SQL console.
  2. Approve / reject / modify are distinct transitions with audit events.
  3. Modify updates the checkpointed draft before send.
  4. Reviewers see evidence, policy reasons, and diffs — not only model prose.
  5. Accountability: reviewer_id on every decision (Ch 29–30).
  6. Safe defaults remain on timeout.

Concrete example

Reviewer opens O-1001. The packet shows the stalled-shipment exception proposal, contact history, draft order update, evidence links, and policy version shop_v1.0.0. They soften one sentence and approve as modify.

1. GET  /cases/O-1001/review  → EvidencePacket + draft + policy_reasons
2. POST /approvals/appr-77/decide
     { "approve": true, "modified_body": "…edited…", "reviewer_id": "rev-17" }
3. Checkpoint.state.draft_body = edited
4. Audit: approval status=modified + body hash
5. Worker resumes → Outreach.send uses edited body

Diagram

stateDiagram-v2
  [*] --> AwaitingApproval
  AwaitingApproval --> Approved: approve
  AwaitingApproval --> Modified: modify
  AwaitingApproval --> Rejected: reject
  AwaitingApproval --> Timeout: timer
  Approved --> Sending: worker resume
  Modified --> Sending: worker resume with new body
  Rejected --> SafeStop
  Timeout --> SafeStop
 Review UI ──► read API (packet)
           ◄── draft, reasons, evidence ids, history
 Review UI ──► decide API
           ──► audit + checkpoint patch + queue signal
 Worker    ──► load checkpoint ──► send approved bytes

Caption: Notice the worker never trusts the original model draft after a modify — checkpoint is source of truth.

Implementation — read/decide + checkpoint patch

Builds on the ApprovalQueue from Ch 29 and AuditLog from Ch 30.

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from shopops.audit import AuditLog, hash_payload


@dataclass
class CaseCheckpoint:
    case_id: str
    tenant_id: str
    fsm: str
    draft_body: str | None = None
    approval_id: str | None = None
    version: int = 1


@dataclass
class ReviewAPI:
    """Teaching stand-in for the case desk backend."""

    queue: Any  # ApprovalQueue from Ch 29
    checkpoints: dict[str, CaseCheckpoint] = field(default_factory=dict)
    audit: AuditLog = field(default_factory=AuditLog)

    def get_review(self, case_id: str) -> dict[str, Any]:
        cp = self.checkpoints[case_id]
        req = self.queue._items[cp.approval_id]
        return {
            "case_id": case_id,
            "fsm": cp.fsm,
            "draft_body": cp.draft_body,
            "packet": {
                "summary": req.packet.summary,
                "policy_reasons": list(req.packet.policy_reasons),
                "evidence_ids": list(req.packet.evidence_ids),
                "action": req.packet.action,
            },
            "approval_id": req.id,
            "status": req.status.value,
        }

    def decide(
        self,
        approval_id: str,
        *,
        reviewer_id: str,
        approve: bool,
        modified_body: str | None = None,
        note: str = "",
    ) -> dict[str, Any]:
        req = self.queue.decide(
            approval_id,
            reviewer_id=reviewer_id,
            approve=approve,
            note=note,
            modified_body=modified_body,
        )
        case_id = req.packet.case_id
        cp = self.checkpoints[case_id]
        if req.status.value == "modified":
            cp.draft_body = req.modified_body
            cp.version += 1
            cp.fsm = "approved_to_send"
        elif req.status.value == "approved":
            cp.fsm = "approved_to_send"
        else:
            cp.fsm = "failed_safe"

        self.audit.append(
            case_id=case_id,
            tenant_id=cp.tenant_id,
            kind="approval",
            actor=f"human:{reviewer_id}",
            payload={
                "approval_id": approval_id,
                "status": req.status.value,
                "note": note,
                "body_hash": hash_payload(cp.draft_body or ""),
                "checkpoint_version": cp.version,
            },
        )
        return {"case_id": case_id, "fsm": cp.fsm, "draft_body": cp.draft_body}


# Worker resume sketch
def resume_send(cp: CaseCheckpoint, outreach_send) -> None:
    assert cp.fsm == "approved_to_send"
    assert cp.draft_body is not None
    outreach_send(body=cp.draft_body)  # not the stale model copy

Failure modes

FailureEffectFix
UI shows packet; worker uses old draftWrong message sentCheckpoint patch on modify
Approve without reading evidenceRubber stamp riskUX forces checklist; sample audits
No audit on modifyReconstruction failsMandatory approval events
Race: two reviewersConflicting decisionsSingle approval_id + CAS version
Timeout then late approveSurprising sendReject late decides; new ticket

Production considerations

  • Keyboard-first case desk; show policy denies chronologically.
  • Metrics: modify rate, time-to-decide, disagreement with model drafts.
  • RBAC: reviewers ≠ engineers with prod DB.
  • Store reviewer-visible redaction views vs sealed full PII packs.
  • Load simulation: burst approvals after an outage — queue depth SLOs.

Chapter summary

  • Case desk APIs make HITL real.
  • Approve / reject / modify are audited transitions.
  • Modify patches checkpointed draft bodies.
  • Workers send approved bytes only.
  • Evidence packets beat chat screenshots.
  • Timeouts stay safe; late approves need new tickets.
  • Reviewer identity is mandatory.
  • The desk is product surface, not an afterthought.

Exercises

  1. Mechanical. Modify a draft via ReviewAPI.decide; assert checkpoint draft_body matches and audit status is modified.
  2. Race. Two decide calls on one approval_id; second must fail; fsm unchanged after first reject.
  3. Design. Wireframe the reviewer screen: which five fields are non-negotiable above the fold?

References

  • Chapters 8, 29–30, 39
  • Human factors / approval workflow patterns. [VERIFY]
  • SR 11-7 — documentation and control challenge processes (governance framing). 2011.