Direct action path only. Approvals need receipts on for the signed dataset.
Decision tracing covers human approve, reject, and edit without exposing approver names or reasons.
When the operator holds an action in PendingApproval, a human decides it.
Each decision is signed as a receipt and stored as one labelled example. This
is layer 3 of the decision architecture.
The dataset feeds the decision model later (build order step 5).
A held action is stored in status.pendingApproval with an id. The
workload stops re-proposing while it waits. A human records a decision with
annotations:
| Annotation | Set by | Value |
|---|---|---|
clawdlinux.org/approval-decision |
client | approve, reject, or edit |
clawdlinux.org/approval-reason |
client | Optional free text, at most 1024 bytes |
clawdlinux.org/approval-edit |
client | For edit: {"name", "description", "params"}, at most 16 KiB |
clawdlinux.org/approval-by |
webhook | JSON {"username", "groups_sha256"} from the admission request |
clawdlinux.org/approval-for |
webhook | The pending action id the decision is for |
clawdlinux.org/approval-mac |
webhook | Hex HMAC-SHA256 over the stamp, see below |
With agentctl:
agentctl approve my-workload --reason "checked the target"
agentctl reject my-workload --rule budget --reason "too many replicas"
agentctl edit-approve my-workload --edit-file edit.json --reason "smaller change"approve and reject keep their legacy annotations and the Argo resume. They
add the protocol annotations only when a direct-path action is pending.
The approver is never taken from the client. The mutating webhook stamps
approval-by and approval-for from the admission request user. It rejects:
- a request that sets any stamp (
approval-by,approval-for,approval-mac) itself, - a change or removal of a decision already recorded for the pending action (decisions are append-once per pending action),
- a decision when no action is pending.
Enable it with agenticOperator.webhook.agentWorkloadEnabled=true. This also
turns on the existing validating rules, for example https MCP endpoints.
Before this change the AgentWorkload webhook registered no handler at all.
SetupWebhookWithManager now wires the defaulter and validator.
The stamp is tamper-evident. The webhook adds approval-mac, an HMAC-SHA256
with a domain separator over:
- the workload UID,
- the pending action
id, - a digest of the decision, reason, and edit annotations,
- a digest of the
approval-bystamp, - a digest of the stored
status.pendingApproval.proposal.
The key is a file named by APPROVAL_STAMP_KEY_FILE, at least 32 bytes. The
chart mounts it from a secret with global.approvals.stampKey.existingSecret
(key stamp.key by default):
kubectl create secret generic approval-stamp-key \
--from-literal=stamp.key="$(openssl rand -hex 32)"The controller recomputes the MAC from the live object and compares with
hmac.Equal. It does not act and sets condition ApprovalDecision to False
when:
| Reason | Cause |
|---|---|
MissingApprovalStamp |
No approval-by stamp |
ApprovalStampKeyMissing |
No usable key configured |
ApprovalStampInvalid |
MAC missing or wrong: forged stamp, decision changed after stamping, workload UID or pending proposal changed |
Approvals fail closed. With no key the webhook rejects new decisions and the
controller refuses all of them. With the webhook disabled nobody can mint a
valid MAC, so a patcher cannot forge an approval even with the right
approval-by JSON. Run the webhook and set the key in any cluster where
approvals matter.
The MAC stops annotation forgery. It does not protect status. A principal
with update or patch on agentworkloads/status can rewrite the pending
action, its proposal, and the consumed id list. Treat such principals as
inside the trust boundary unless a status admission policy is also enforced.
Only these subjects should hold that verb:
- the operator manager service account (
manager-role), - cluster admins (
cluster-admin).
Do not grant agentworkloads/status to agents or CI bots. Today the
agentctl-web service account (charts/templates/webui.yaml) holds patch
and update on it, so the web UI is inside the trust boundary. Do not expose
it to untrusted users.
On a stamped decision for the current pending id:
- Approve and edit re-run invariants, policy packs, and the threshold on the action that would execute. Edit runs them on the edited action.
- It records the decision in
status.pendingApproval.state(Recording). - It writes a layer
humanreceipt: outcomeapproved,rejected, oredited. The receipthuman_principalis the approver identity digest (see receipts), never the raw username. Reasons hold the same digest,sha256:of the reason, and the pendingid. The raw username appears only in theApprovalDecisioncondition message, so an operator can see which RBAC subject decided. - With
RECEIPTS_REQUIRED=trueand a failed write, it stops. Condition reasonReceiptFailed. The action stays pending. - It appends the dataset example.
- Then it acts:
reject: phaseRejected. Never executes. Terminal for the direct path.approveoreditblocked by an invariant, a pack deny, or a pack error: a deny receipt follows, phasePolicyDenied. A human resolves threshold and pack approval findings. Never invariants.- otherwise: state
Executing, thenexecute_action, thenCompletedorFailedas on the allow path.
Pending ids do not repeat. With receipts on, the id is derived from the decision receipt entry hash and the workload UID, so a new pending action always gets a new id and an old stamped decision is stale for it.
A decided id is consumed. The controller keeps the last 32 decided ids in
status.consumedApprovalIDs. The human receipt records
consumed_id_sha256, a domain-separated digest of the workload UID and
pending id, and pending_payload_sha256, the digest of the stored proposal
the decision was stamped against. If a status write restores a pending action
whose id is consumed, the controller does not act. Condition reason
ApprovalReplay.
The stamp MAC binds the decision to the workload UID and the stored proposal
digest. A decision copied to a recreated workload, or a proposal rewritten
after stamping, gives reason ApprovalStampInvalid.
status.lastApproval records the outcome: executed, failed, rejected,
denied, or unknown.
Replays are safe. A stale cached object fails the Recording status update
before any side effect. If the operator stops while Executing, the next
reconcile does not run the action again. It marks Failed with outcome
unknown. A crash between the receipt write and the next status update can
write a second identical human receipt. It cannot execute twice.
spec.approvalCapture.content controls what the dataset stores about the
action:
| Mode | Stores |
|---|---|
none |
action_sha256 and detected class names only |
redacted (default) |
Name, description, and params. Any string holding a detected class or credential becomes [class] |
full |
Raw name, description, and params |
full may hold personal data. Choose it explicitly and treat the writer volume
as personal data storage. Map keys are not scanned in redacted mode.
Receipts and decision records never hold raw params, in any mode.
status.pendingApproval.proposal holds the raw MCP proposal while an action
waits, because approve must execute it unchanged. Anyone who can read the
workload can read it.
The receipt-writer owns approvals.jsonl in its data dir. Routes, same bearer
token as receipts: POST /v1/approvals (strict decode, 256 KiB cap, unknown
fields and trailing data rejected) and GET /v1/approvals. The
writer rejects an example that does not bind to a stored human receipt (422)
and a second example for the same receipt or pending id (409).
One example per line:
| Field | Meaning |
|---|---|
schema_version |
1 |
example_id |
Pending action id |
receipt_seq, receipt_entry_hash |
The human decision receipt |
workload |
Namespace, name, uid |
features |
Copied from the original decision record: original seq and input hash, declared decision type and allow lists, observed destination host, data classes, tool, history counts, claimed confidence and health (decimal strings), packs, threshold mode, escalated_by layer, reason codes |
label |
approve, reject, or edit |
reason_sha256, approver_sha256 |
Hashes only |
timestamp |
RFC 3339 UTC |
capture_mode, content |
Per the capture mode |
edit_diff |
For edit: fields changed (name, description, params) and the edited content per mode |
source |
Absent for a human decision. synthetic marks generated training rows from tools/decision-train. Synthetic rows never bind to a receipt, so verify rejects them |
A failed dataset write is logged and shown in condition ApprovalDataset. It
does not block the decision.
agentctl dataset export --writer http://localhost:8080 \
--token-file ./token --out ./dataset
agentctl dataset verify ./dataset --trust-root ./pinned-trust.jsonverify requires --trust-root, a trust file pinned out of band. The export's own
trust.json is not trusted, because anyone who can replace the export can replace
it too. --allow-embedded-trust runs a consistency-only check and prints a
WARNING. verify runs the same chain and record checks as agentctl receipts verify,
including the signed manifest completeness check. --allow-prefix works the
same way and prints the same WARNING.
Then for each example it checks that receipt_seq and receipt_entry_hash
exist in the chain, that the receipt is a human record, and that the label,
pending id, approver hash, reason hash, and workload match it. It prints PASS
or FAIL lines and exits 1 on any failure.
- Orchestrated (Argo) workloads: the approval gate is Argo
suspend, resumed byagentctl approve. That path has no stamped identity, no human receipt, and no dataset example. Not changed here. agentctl-webapprovals are stamped with the web service account, not the signed-in user.- Receipts off means no human receipt and no dataset example. Decisions still work.
- Lost examples: if the dataset write fails, the label is not retried.
Rejectedis terminal. Recreate the workload to retry.POST /v1/approvalsbinding check scans the chain files. Fine for now, slow for very long chains.- No synthetic seed set yet. The dataset starts empty.
- Dataset examples are bound to receipts by identity and label only. Feature edits and deleted rows in an export pass
dataset verify(BLOCKERS.md, F7 and F12).
The stamp binds a decision to the stored proposal, not to the spec. So the
webhook rejects changes to spec.declaredIntent, spec.policyPacks,
spec.opaPolicy, spec.decisionModel and spec.approvalCapture while an
action is pending or a decision is being recorded. spec.mcpServerEndpoint is
always immutable. Approve, reject or edit the pending action first, then change
the spec.