Approval roles, deadlines and escalation
Attestation service timeout
The adapter's technical response has a default timeout of 5,000 ms. For a slower identity service, set this timeout between 1 and 60,000 ms:
touba run --project . --human-approval-host host/approval.mjs --human-approval-authority-timeout-ms 12000
touba serve --project-root . --human-approval-host host/approval.mjs --human-approval-authority-timeout-ms 12000
The JavaScript SDK exposes humanApprovalAuthorityTimeoutMs; Python uses
human_approval_authority_timeout_ms. A timeout refuses the attestation;
a late response cannot release the operation.
The approver's business deadline remains the one declared in the program.
This local tutorial uses Touba and Node.js 24, with no model or paid service. Touba declares the business rule; the trusted host verifies the human identity.
1. Declare the rule
Create approval.tb in an empty test directory:
module examples.approval_roles
strategy ApproveInvoice
takes invoice as Text
gives Text
do
human.approve
with context as invoice
with role as "finance"
with timeout_ms as 30000
with distinct_from as "initiator"
into approval
on expired
human.approve
with context as invoice
with role as "finance_manager"
with timeout_ms as 30000
with distinct_from as "initiator"
into escalation
give "Accepted by the manager"
give "Accepted by finance"
The first continuation requires a signer with the finance role who is not
the initiator. After 30 seconds, the explicit expiration branch requests a
new approval with the finance_manager role. A restart preserves the deadline.
Escalation is another request, not approval. Both paths return text here so the
outcome is visible without external effects.
For two separate signatures, write two approvals with
distinct_from as "initiator_and_previous". Touba keeps accepted signers
across nested calls and parallel branches within the run.
2. Supply the host attestation
import { readFileSync } from "node:fs";
export const initiatorRef = "employee:requester";
export function attestHumanApproval({ request, decision, evidenceRef }) {
// Local teaching adapter: these files are written only by the trusted host.
const evidence = JSON.parse(readFileSync(new URL("./host-evidence.json", import.meta.url), "utf8"));
const directory = JSON.parse(readFileSync(new URL("./host-directory.json", import.meta.url), "utf8"));
if (evidence.id !== evidenceRef || evidence.request_id !== request.request_id
|| evidence.run_id !== request.run_id || evidence.decision !== decision) {
throw new Error("Evidence does not match the current request");
}
const roles = directory[evidence.subject_ref];
if (!Array.isArray(roles)) throw new Error("Signer is not active");
return { request_id: request.request_id, run_id: request.run_id, decision,
subject_ref: evidence.subject_ref, roles };
}
This teaching adapter reads evidence and a directory owned by the trusted host. In production, use your identity service and authenticated decision store. A client supplies an opaque evidence reference, not authoritative roles. Verify the exact request, run, decision, authenticated identity and current rights. The adapter is trusted host code, never a model-accessible tool.
3. Check refusal and resume
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
function run(args, expectedExit) {
const child = spawnSync("touba", ["run", "approval.air.json", "--entry",
"strategy:examples.approval_roles/ApproveInvoice",
"--human-approval-host", "host.mjs", "--output", "json", ...args], { encoding: "utf8" });
assert.equal(child.status, expectedExit, child.stdout + child.stderr);
return JSON.parse(child.stdout);
}
function save(path, value) {
writeFileSync(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
}
const paused = run(["--inputs-json", JSON.stringify({ invoice: "Invoice INV-42" }),
"--human-checkpoint-out", "approval.checkpoint.json"], 3);
assert.equal(paused.reason, "human_approval_pending");
const checkpoint = JSON.parse(readFileSync("approval.checkpoint.json", "utf8"));
const request = checkpoint.approval_state.requests[checkpoint.approval_ref];
save("host-evidence.json", { id: "signed-decision:42", request_id: request.request_id,
run_id: request.run_id, decision: "approved", subject_ref: "employee:reviewer" });
save("decisions.json", { [checkpoint.approval_ref]: {
decision: "approved", evidence_ref: "signed-decision:42" } });
const resume = ["--human-resume-checkpoint", "approval.checkpoint.json",
"--human-decisions", "decisions.json"];
save("host-directory.json", { "employee:reviewer": [] });
assert.equal(run(resume, 1).reason, "HumanApprovalRoleRequired");
save("host-directory.json", { "employee:reviewer": ["finance"] });
const accepted = run(resume, 0);
assert.equal(accepted.result, "Accepted by finance");
assert.ok(!JSON.stringify(accepted.trace).includes("employee:reviewer"));
console.log("APPROVAL_ROLE_AND_RESUME_OK");
touba compile approval.tb --out approval.air.json
node exercise.mjs
Expected output: APPROVAL_ROLE_AND_RESUME_OK. The first attempt fails because
the signer lacks the role. The second succeeds after the host directory changes.
These test receipts must not be used for real business approvals.
To observe escalation, create a new waiting run and let its deadline pass.
Resume its checkpoint without a decision, requesting a new checkpoint
output. The result is another wait for finance_manager, not the initial
success result. The CLI observes expiration when resumed; touba serve also
has a local worker that observes deadlines automatically.
Integration and storage
- CLI and server:
--human-approval-host host.mjs. - JavaScript
Engine: thehumanApprovalHostoption points to this module. - Embedded runtime: pass the function as
humanApprovalAuthorityand the opaque initiator identity ashumanApprovalInitiator. - The adapter receives
request,decision,evidenceRefandsignal. It returnsrequest_id,run_id,decision,subject_refandroles, plusexcluded_signerswhen a cross-run exclusion rule is declared. Its default time limit is five seconds. - The server attests before durably accepting a decision. A timely accepted decision remains valid for that occurrence after restart. New requests verify current rights again.
Keep checkpoints and evidence private, outside Git and public logs. They are trusted host state, not cryptographic proof against that host. Approval grants no tool permission and does not replace an effect contract or journal. An unavailable identity service or invalid evidence fails before the protected continuation.
Typed result and rejection branch
human.approve … into approval produces an Approval value, with fields
checked at compile time: approval.decision, approval.subject_ref,
approval.attested_roles, approval.request_id, approval.run_id and
approval.accepted_at, plus the optional approval.comment.
module examples.approval_outcomes
strategy Review
takes invoice as Text
gives Text
do
human.approve
with context as invoice
with role as "finance"
with timeout_ms as 30000
into approval
on rejected
give approval.decision
on expired
give approval.decision
give approval.decision
on rejected and on expired paths end with give or stop. They may call
a notification tool under its usual grants. They cannot join the approved path.
Per-run initiator and separation across runs
The host module can export resolveInitiator({run_id, entry_ref, inputs, signal})
in JavaScript or resolve_initiator(request) in Python. It verifies submission
evidence in application storage and returns an opaque identity. Touba resolves
it once and preserves it in the checkpoint. The client's requested_by field
does not attest identity.
To exclude signers of other records, add
with exclude_signers as "invoice_approvers" to the approval. The host reads
verified invoice evidence for the batch and completes its attestation:
{
"request_id": "request:example",
"run_id": "run:example",
"decision": "approved",
"subject_ref": "employee:treasurer",
"roles": ["finance"],
"excluded_signers": {
"rule": "invoice_approvers",
"subject_refs": ["employee:reviewer"]
}
}
The list comes from evidence verified by the host. Touba requires the exact source-declared rule, refuses missing evidence and blocks a signer on that list. This private attestation is never produced by the model.
Send a decision to the server
POST /decisions accepts evidence_ref, an opaque reference to host evidence.
The adapter also receives hostDecision in both SDKs, the
complete decision document. It must bind the attested signer to user_ref
and decided_by, as well as the request, run and decision. Without
evidence_ref, the passed reference is request_id. The public request
includes policy so the UI can show role and separation requirements.
See the server API.
Comment on a decision and share rejection handling
The decision body accepts an optional comment of up to 4,096 characters.
approval.comment is an Optional of Text: some carries the comment and absence
is explicit. This free text supplies no role or identity evidence.
Share handling between on rejected branches with a pure function, or a skill
with its own declared effects:
module examples.shared_refusal
function RefusalMessage
takes approval as Approval
gives Text
do
if present approval.comment as message
give message
give "Request rejected"
strategy Review
takes invoice as Text
gives Text
do
human.approve
with context as invoice
into approval
on rejected
RefusalMessage
with approval as approval
into message
give message
give "Request approved"
Several branches can call the same function. Each call passes its decision and data explicitly.