One queue for everything waiting on the operator. Agents request; only the operator decides; an approval binds to an exact payload that pTask itself stores and hashes; executors consume an approval exactly once.
Human ids are AP-<n>, minted from pt_counters.approval_id the same way
tasks mint PT-<n>.
| Role | Who | What they may do |
|---|---|---|
| Requester | any write-capable actor ($PTASK_ACTOR, HTTP client_id, MCP actor) |
request, withdraw (own pending rows), list/show |
| Operator | a human at a TTY, the dashboard sidecar holding PTASK_DASH_DECIDE_TOKEN, Telegram (operator chat, with PTASK_TG_APPROVAL_BUTTONS=1), or an admin HTTP token |
approve / reject / decide, payload --any-status (inspect) |
| Executor | a script or agent holding the approved bytes | payload → act → consume |
A request is pending until it is approved, rejected, withdrawn, or expired.
Decisions are immutable. The SQLite triggers on approvals refuse:
- any change to
digest,payload, orpayload_kindafter insert - any change to a non-pending row except the one-time consume latch
(
consumed_at/consumed_byNULL → a value, and only on an approved row)
notified_at may still be set while the row is pending.
Exactly one of:
-
--payload-file F— store the file bytes (max 256 KiB).payload_nameis the basename,payload_refis the path. Larger files error and the message tells the caller to use--digest. -
--payload-json J— parse JSON and store the canonical form: keys sorted recursively, compact separators, non-ASCII kept as UTF-8, floats in Pythonreprform. For every payload pTask accepts this is byte-identical to Pythonjson.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False). One digest must name one payload, so input that different parsers read differently is refused, not normalised: duplicate object keys, integers beyond 64 bits, non-integers of magnitude 2^53 or more, number literals with more than 17 significant digits (0.1000000000000000000001), a nonzero literal that underflows to zero (1e-400), and-0. Send such values as strings. Round-tripping literals that are not the shortest form (0.10000000000000001, as C%.17g, jq 1.6 or Postgresextra_float_digits=3print them) are accepted and stored in Python's shortest form (0.1).payload_jsonover HTTP and MCP is parsed by the transport first, so there only the number-range rules can apply; the strict text rules bind again atverify/consume, where the executor's--payload-jsonis parsed the same way.JSON approvals stored before strict canonicalisation shipped used the old form (ryu float digits, no number limits). Re-canonicalising the executor's JSON can disagree with them: a float of magnitude 2^53 or more (
1e16) is now refused, soverify/consume --payload-jsonexits 1; an exponent-range float (old1e-5, now1e-05) or a 17-digit float the old parser rounded differently gives a digest mismatch, exit 5. For such rows fetch the stored bytes and bind to them instead:pt approval payload AP-n > p.jsonthenpt approval consume AP-n --payload-file p.json. -
--digest H— 64 lowercase hex; nothing is stored (payload_stored: false).
digest is always SHA-256 of the stored bytes (or the supplied digest when
nothing is stored). The same requester re-requesting the same digest while
their row is still pending gets that row back (partial unique index on
(lower(requester), digest) WHERE status='pending'). A different requester
asking for the same payload gets a fresh AP-n of their own: dedupe never
hands one actor another actor's approval.
A re-request returns the existing row unchanged: a new --expires-in,
title, note, kind or task is not applied (expiry is never moved silently).
The response carries "deduplicated": true and, when something requested
was not applied, a "notice" naming it (… not applied: expires_in "3h" (kept expires_at …), title); the CLI also prints the notice on stderr, and
HTTP answers 200 instead of 201. To change those fields, withdraw the
request and request again.
preview is rendered by pTask from the stored payload, never from the
requester's prose:
- UTF-8 text is shown as-is
- JSON is pretty-printed
- non-UTF-8 →
<binary N bytes> - digest-only → a line saying the payload is not stored
Agent prose belongs in request_note (--note / --note-file) and is always
shown labelled as the requester's note.
Text fields are bounded on every surface: title 300 characters, the
requester's note and the operator's decision note 16384 characters each,
payload_name 255 characters. Longer values are refused, not truncated.
Kinds: email, ebay, spend, destroy, external, budget, other.
The executor pattern:
pt approval payload AP-12 > /tmp/letter.html
# send / publish / transfer using those exact bytes
pt approval consume AP-12 --payload-file /tmp/letter.htmlpayload releases the bytes only while the approval is in force:
approved, not past expires_at, not yet consumed. Otherwise it writes
nothing to stdout and exits 3 (pending), 4 (rejected / withdrawn / expired)
or 6 (already consumed). A digest-only request has no bytes to release
(exit 1).
Every approval JSON object (show/ls --json, HTTP, MCP) carries
"in_force": true|false, the same verdict computed at read time. Pollers
should wait on in_force, not on status: status stays approved after
the approval expires and after it is consumed.
What this gate is, and is not. It is a correctness guard for executors
that follow the documented payload → act → consume pattern: such an
executor cannot act on bytes the operator never approved, even if it forgets
to check the status. It is not a confidentiality boundary, and it does
not withhold the bytes from an agent that wants them:
previewis the full text of any UTF-8 payload (JSON pretty-printed) and is on every read surface at every status:show/ls --json, HTTPGET /api/approvals[/{id}], MCPapproval_status/approval_list, and the dashboard sidecar'sGET /api/approvals, which needs no auth since PT-2201 (the tailnet is the gate). It must be, since it is what the operator decides on.payload --any-status(the operator's inspection path, for example a binary file whose preview is<binary N bytes>) prints the bytes whatever the status. It is refused whenCLAUDECODEis set or stdin is not a TTY, but that guard is a speed bump against accidents: any process can allocate a pseudo-terminal (script -qc …) and unset the variable.
Do not put secrets in an approval payload on the assumption that only an approved executor will see them.
verify is the same check without the latch. Exit codes (both verbs):
| Code | Meaning |
|---|---|
| 0 | approved, digest matches, not yet consumed (consume then latches) |
| 3 | pending |
| 4 | rejected / withdrawn / expired, or approved or pending but past expires_at |
| 5 | digest mismatch (consume does not latch) |
| 6 | already consumed |
| 1 | anything else (unknown id, bad args) |
pt approve (and pt approval decide … approve) exits 7 when it refuses a
flagged payload preview without --force (see Authority rules). Approving
or rejecting a request that is no longer pending exits 4 (its terminal state
is named); the --force check only applies to a pending request.
Both accept exactly one of --payload-file / --payload-json / --digest,
canonicalised identically to request.
Local CLI (pt approve / pt reject / pt approval decide):
- refused when
CLAUDECODEis set and non-empty (even with--via dashboard) - refused when stdin is not a TTY unless
--via dashboard decided_viaisclion a TTY,dashboardwith--via dashboarddecided_byis$PTASK_ACTOR- the requester cannot decide their own request; actor names compare
trimmed and ASCII case-insensitively (
HALishal), here and for withdraw-own-rows - approve (not reject) refuses without
--forcewhen the payload preview holds control, bidi or invisible characters (a zero-width space in an address, tag characters after an amount): the digest binds the stored bytes, not what a screen shows.pt approval showmarks them as U+FFFD under a warning; inspect the exact bytes withpt approval payload AP-n | cat -v, thenpt approve AP-n --force. The refusal exits 7. The dashboard sidecar path (--via dashboard) is gated the same way and never forces: the sidecar answers 409{"code": "payload_flagged"}with pt's message plus "approve from the CLI withpt approve AP-n --force", and the cockpit shows that as its toast. The check only applies to a pending request. - what counts: control characters other than LF/tab/CRLF, bidi controls,
every Unicode format (Cf) and Default_Ignorable code point — including
a ZWJ (U+200D) and a soft hyphen (U+00AD), the Khmer inherent vowels,
unassigned U+FFF0–FFF8 and the whole U+E0000–E0FFF block — the
line/paragraph separators, the blank-rendering braille blank (U+2800) and
ideographic space (U+3000), and variation selectors (U+FE00–FE0F, U+E0100–E01EF, the
"emoji smuggling" carrier) except exactly one VS15/VS16 directly after
a pictograph (real emoji, not enclosed alphanumerics or plain arrows) or
in a keycap — and at most 8 of those per payload: each optional selector
can carry about 1.6 bits that render identically, so a 9th flags it. So a lone VS16 emoji such as ❤️, ☀️ or ✔️ in
an email body needs no
--force, while a second selector in a run, any other selector, a ZWJ sequence such as a family emoji (👨👩👧) — the preview never keeps joiners — or a soft hyphen still does.
HTTP POST /api/approvals/{id}/decide requires admin scope;
decided_via=api. Write-scope tokens may request and withdraw (own rows
only). Read-scope tokens may list and get.
Dashboard sidecar POST /api/approvals/AP-n/approve|reject (the cockpit's
inbox) runs pt approve|reject AP-n --via dashboard, so it is the one route
that reaches the CLI's decide path without a TTY. The sidecar has no login
(PT-2201) and the tailnet carries the fleet's agents as well as the operator,
so the route requires the X-PTask-Decide-Token header to equal
$PTASK_DASH_DECIDE_TOKEN. With the variable unset, or shorter than 16
characters, the sidecar refuses every decision (403, decide_disabled); a
missing or wrong header gets 403 decide_token_required. The cockpit asks for
the token once and keeps it in the browser's localStorage.
Telegram /tg/callback verbs ptapprove:AP-n / ptreject:AP-n are accepted
only when $PTASK_TG_APPROVAL_BUTTONS=1 (the switch that puts decide buttons on
the pings), the authenticated client_id is in $PTASK_TG_FORWARDERS
(default nexus) and from_id equals $PTASK_ACCOUNTABILITY_CHAT_ID.
Otherwise 403 and no state change. An approve tap is also refused (403,
"review in the inbox") when the request is not tap-decidable by the rules
under Notify, whatever ping the tap came from: pings sent by older builds
carried buttons under looser rules. Reject taps stay allowed.
decided_via=telegram,
decided_by=operator@telegram. Idempotent per callback_id. The
ptdone: / ptsnooze: / ptdismiss: verbs are journaled as telegram (the
operator's tap) only from a forwarder; any other write client is journaled as
telegram via <client_id>.
MCP exposes approval_request, approval_list, approval_status,
approval_withdraw. No MCP tool can decide. pt mcp takes its actor from
$PTASK_MCP_ACTOR, else $PTASK_ACTOR, else mcp (never the CLI's
default shell unless you set it), so the operator's own pt approve
(actor shell) is not mistaken for the requester. PTASK_MCP_ACTOR wins
so that an operator shell exporting PTASK_ACTOR=shell does not pass that
identity to an MCP server it launches; set PTASK_MCP_ACTOR in each MCP
registration. Two unconfigured MCP clients share mcp.
A new request (not the same requester's idempotent re-request) best-effort pings the
operator Telegram chat: AP-n, kind, title, requester, a bounded HTML-escaped
excerpt of preview, the requester note labelled as such, a digest prefix,
and an inline URL button to $PTASK_DASH_URL/#approvals (omitted if
unset). Tap-to-decide callback buttons are added only when
PTASK_TG_APPROVAL_BUTTONS=1 and the message shows the whole payload
faithfully. Budgets are in UTF-16 code units, which is what Telegram's
4096 limit counts (an emoji is 2): preview 2000, note 800, title 200,
requester 100. A longer preview is cut (never inside a surrogate pair),
marked PREVIEW TRUNCATED with the shown and total unit counts, and gets
no decide buttons, so padding cannot push a harmful tail out of sight of a
one-tap approval. Binary and digest-only payloads are marked "Payload not
shown"; a preview containing bidi embeddings, overrides or isolates
(U+202A–202E, U+2066–2069), zero-width / default-ignorable characters, or
a control character other than a newline, tab or CRLF (a lone CR or a
backspace can overwrite what is shown) is marked as containing invisible or
direction-changing characters. Neither gets decide buttons. This is
deliberately conservative: emoji that use a joiner (👨👩👧) or a variation
selector (❤️, ✔️, keycaps) also lose tap-to-decide. If a message would still exceed 4096 units, the note
is dropped first. Those requests are decided from the inbox, the CLI, or
an admin token. Send failure never fails the request; success sets
notified_at.
pt approval notify retries pending rows with notified_at NULL. The same
sweep runs from pt accountability run so the existing 15-minute timer
retries a failed ping.
A pending request past expires_at cannot be decided: approve/reject
flips it to expired and refuses. pt accountability run (the 15-minute
timer) sweeps stale rows before pinging, and a re-request of an expired
payload mints a fresh AP-n instead of returning the stale one.
pt approval expire runs the same sweep on demand (idempotent).
expires_at bounds execution too, not only the decision window. It is
one deadline for the whole approval: an approval granted five minutes
before its expires_at is usable for those five minutes and then dies.
Pick --expires-in to cover the time the executor needs after the
decision, not just the time the operator needs to decide. An approved row
whose expires_at has passed stays approved in the record (decided rows
are frozen), but payload, verify and consume refuse it with exit 4
unless it was already consumed. Ask for a fresh approval instead.
A pending row past expires_at that no sweep has flipped yet is reported
the same way (exit 4, "expired"), not as pending (3): it can no longer be
approved. An expires_at that does not parse as a timestamp (pTask never
writes one; only damage or a hand edit can) is treated as already past:
the row cannot be decided, released, verified or consumed.
pt approval request --kind email --title "Send the Q3 memo" \
--note-file note.md --payload-file letter.html
pt approval ls
pt approval show AP-12
pt approve AP-12 # operator TTY
pt approve AP-12 --force # after inspecting a flagged payload
pt reject AP-12 --via dashboard # dashboard sidecar
pt approval consume AP-12 --payload-file letter.htmlHTTP: GET/POST /api/approvals, GET /api/approvals/{id},
POST /api/approvals/{id}/withdraw, POST /api/approvals/{id}/decide.
Every pTask writer — CLI, MCP stdio, HTTP tokens, the dashboard sidecar —
runs as one Unix user on tensor-core against one SQLite file. These
rules are guardrails against an agent approving its own request by
accident (a CLAUDECODE session calling pt approve, an MCP tool named
approve, a write-scope token hitting /decide, a tailnet agent POSTing to
the open dashboard sidecar). They are not a cryptographic boundary. The decide
token lives in the sidecar's environment file, which any process of the same
user can read. Anyone who can write the database, replace the
binary, or steal an admin token can decide. The tamper triggers raise the
cost of a confused-deputy UPDATE; they do not stop a process with the
same uid from disabling them. Payload bytes are not secret from any of
these writers either: see "What this gate is, and is not" under Consume.