Human in the loop

Harn's human-in-the-loop surface is first-class typed syntax. ask_user, request_approval, dual_control, and escalate_to are reserved keywords parsed as language-level expressions (not regular function calls). The VM enforces waiting, timeouts, quorum, escalation, signed receipts, the event log, and replay determinism, and since the names are reserved, user code cannot shadow them, redefine them, or compose around them to bypass approval.

Use import "std/hitl" when you want shared type aliases such as ApprovalRecord or EscalationHandle. The keywords themselves are always available — no import is required.

Call shapes: named or positional

Each primitive accepts either named arguments (the recommended form) or the legacy positional shape. Both lower to the same VM-enforced runtime, so existing scripts keep working without migration:

// Named-argument form (recommended)
let record = request_approval(
  action: "deploy production",
  quorum: 2,
  reviewers: ["alice", "bob", "carol"],
)

// Positional form (kept for back-compat with the original stdlib API)
let record = request_approval(
  "deploy production",
  {quorum: 2, reviewers: ["alice", "bob", "carol"]},
)

The typechecker validates required arguments and rejects unknown names per primitive (e.g. request_approval(bogus_arg: 1) is a compile-time error).

VM-enforced invariants

Because the four primitives are language-level keywords:

  • Names cannot be shadowed. let request_approval = "fake" is a syntax error (reserved keyword), so user code cannot replace the primitive with a counterfeit function or value.
  • Approval cannot be bypassed by composition. The result envelope is produced by the VM, including a SHA-256 receipt over reviewer identity, signing time, and the approval verdict. Forging the envelope by constructing a dict literal does not yield valid signatures, so downstream consumers can verify provenance.
  • Dual-control quorum requires distinct principals. The runtime deduplicates reviewer IDs before counting approvals; replaying a single signer's approval cannot satisfy n of m.
  • Audit log records every decision. Each request, response, approval, denial, and timeout is appended to the event log on a topic determined by the primitive.
  • Replay is deterministic. Given the same recorded approval outcomes, a replay of a script produces the same envelope (including the same signatures), enabling reproducible audits.

Primitives

ask_user<T>(prompt: string, options?: {schema?: Schema<T>, timeout?: duration, default?: T}) -> T

Pause the current dispatch until the host returns a typed response.

  • If schema is present, the returned value must satisfy it.
  • If default is present and no schema is supplied, Harn coerces the host response toward the default's type when possible.
  • timeout defaults to 24 hours.
  • If the wait times out, Harn returns default when present; otherwise it throws HumanTimeoutError.
  • Event log:
    • request: hitl.question_asked
    • response: hitl.response_received
    • timeout: hitl.timeout
type Choice = {
  environment: "staging" | "prod",
}

let choice: Choice = ask_user(
  "Where should this deploy?",
  {schema: schema_of(Choice)},
)

request_approval(...) -> ApprovalRecord

request_approval(
  action: string,
  options?: ApprovalRequestOptions,
) -> ApprovalRecord

type ApprovalRequestOptions = {
  detail?: any,
  args?: any,
  quorum?: int,
  reviewers?: list<string>,
  deadline?: duration,
  principal?: string,
  evidence_refs?: list<dict>,
  undo_metadata?: dict,
  capabilities_requested?: list<string>,
}

Emit an approval request and wait for a quorum of approving reviewers.

  • quorum defaults to 1.
  • deadline defaults to 24 hours.
  • If reviewers is omitted, any authorized reviewer may approve.
  • args is the canonical host-rendered argument payload. When omitted, Harn uses detail for compatibility with older scripts.
  • principal defaults to the current dispatch agent or session.
  • evidence_refs, undo_metadata, and capabilities_requested are forwarded unchanged for hosts that render provenance, undo, or capability UX.
  • Denial raises ApprovalDeniedError, which scripts can catch with try.
  • Event log:
    • request: hitl.approval_requested
    • responses: hitl.response_received
    • approved: hitl.approval_approved
    • denied: hitl.approval_denied
    • timeout: hitl.timeout

ApprovalRecord is the shared shape:

type ApprovalRecord = {
  approved: bool,
  reviewers: list<string>,
  approved_at: string,
  reason: string | nil,
  signatures: list<ApprovalSignature>,
}

type ApprovalSignature = {
  reviewer: string,
  signed_at: string,
  signature: string,
}

Each counted approval contributes one signature receipt. Hosts may provide a signature directly; otherwise the VM records a deterministic receipt signature over the request id, reviewer, approval decision, reason, and signed timestamp.

ApprovalRequest is the canonical host-rendered request shape used by stdlib HITL, bridge permission prompts, and external approval inboxes:

type ApprovalRequest = {
  id: string,
  action: string,
  args: any,
  principal: string,
  requested_at: string,
  deadline: string | nil,
  approvers_required: int,
  evidence_refs: list<dict>,
  undo_metadata: dict | nil,
  capabilities_requested: list<string>,
}

For stdlib HITL events, the durable request envelope stores this under payload.approval_request and also mirrors the same fields at payload.id, payload.action, payload.args, and so on. Legacy detail, quorum, reviewers, and deadline_ms fields remain present so older hosts continue to render and resolve existing flows.

dual_control<T>(n: int, m: int, action: fn() -> T, approvers?: list<string>) -> T

Run a closure only after n approvals out of m named approvers.

  • Typical destructive-operation pattern: dual_control(2, 3, { -> ... }, ["alice", "bob", "carol"])
  • The closure does not run until quorum is satisfied.
  • Denial raises ApprovalDeniedError.
  • Event log:
    • request: hitl.dual_control_requested
    • responses: hitl.response_received
    • approved: hitl.dual_control_approved
    • denied: hitl.dual_control_denied
    • executed: hitl.dual_control_executed
    • timeout: hitl.timeout

escalate_to(role: string, reason: string) -> EscalationHandle

Raise the current dispatch to a higher-trust role and block until the host accepts the escalation.

  • The request is persisted before the dispatch pauses.
  • The request payload includes the active capability policy when one is installed, so hosts can route or resolve the requested role against the same capability ceiling the VM is enforcing.
  • The host resolves it by appending an acceptance event.
  • If nobody accepts it, the dispatch remains paused until a host or operator resumes it explicitly.
  • Event log:
    • request: hitl.escalation_issued
    • acceptance: hitl.escalation_accepted

EscalationHandle is the shared shape:

type EscalationHandle = {
  request_id: string,
  role: string,
  reason: string,
  trace_id: string,
  status: string,
  accepted_at: string | nil,
  reviewer: string | nil,
}

hitl_pending(filters?: HitlPendingFilters) -> list<HitlPendingRequest>

Read the active event log and return pending HITL requests as typed rows.

  • Returns [] when no event log is attached.
  • Reads and merges hitl.questions, hitl.approvals, hitl.dual_control, and hitl.escalations.
  • Filters are all optional:
    • since / until: inclusive RFC3339 timestamp bounds
    • kinds: subset of "question" | "approval" | "dual_control" | "escalation"
    • agent: exact agent id match
    • limit: defaults to 500, capped at 5000
  • Results are sorted newest first and omit requests that already reached a terminal HITL event.

HitlPendingRequest is the shared row shape:

type HitlPendingRequest = {
  request_id: string,
  request_kind: HitlRequestKind,
  agent: string,
  prompt: string,
  trace_id: string,
  timestamp: string,
  approvers: list<string>,
  metadata: dict,
}

Event topics

HITL records are written to dedicated durable topics:

  • hitl.questions
  • hitl.approvals
  • hitl.dual_control
  • hitl.escalations

These append through the normal event-log path, so they get the same crash-safety guarantees as trigger dispatch records.

Host contract

When a builtin opens a HITL wait, Harn emits a bridge notification:

  • harn.hitl.requested

For approval and dual-control requests, notification params are the durable request envelope and include payload.approval_request with the canonical ApprovalRequest object. Hosts should render that object instead of inventing host-specific approval shapes.

Hosts resolve pending requests with the JSON-RPC method:

  • harn.hitl.respond

The response payload includes the request_id plus the relevant fields for that request kind:

  • questions: answer
  • approvals / dual control: approved, reviewer, optional reason
  • escalations: accepted, reviewer, optional reason

ACP and MCP both expose harn.hitl.respond. The orchestrator CLI also exposes manual escalation resume via harn orchestrator resume <request_id>.

Replay semantics

Replay is event-log-driven.

  • Live dispatch: the host provides responses through harn.hitl.respond.
  • Replay: the VM reads the previously recorded hitl.response_received or hitl.escalation_accepted events instead of consulting a live host.

This makes trigger_replay(...) and harn trigger replay <event-id> replay-safe for HITL flows as long as the original run recorded the HITL response events. Approval reviewer identities, signed timestamps, and receipt signatures are reused from those events during replay.

Patterns

Catch denials explicitly:

let result = try {
  request_approval("deploy production", {quorum: 2, reviewers: ["alice", "bob"]})
}
if is_err(result) && unwrap_err(result).name == "ApprovalDeniedError" {
  log("deployment denied")
}

Gate a destructive step behind dual control:

let deleted = dual_control(2, 3, {
  delete_file("prod.db")
  return true
}, ["alice", "bob", "carol"])