Skip to main content
Version: 1.0.0

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:

approval.tb
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​

host.mjs
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​

exercise.mjs
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");
Terminal
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: the humanApprovalHost option points to this module.
  • Embedded runtime: pass the function as humanApprovalAuthority and the opaque initiator identity as humanApprovalInitiator.
  • The adapter receives request, decision, evidenceRef and signal. It returns request_id, run_id, decision, subject_ref and roles, plus excluded_signers when 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.