Bind an approval to the action that will actually run
Define an exact action identity, distinguish integrity from authority, and reject changes between review and execution.
The decision
A reviewer approves a configuration change for the checkout service in the western lab. The worker waits in a queue. Before it runs, someone changes the target to the eastern lab and rebuilds the artifact. The ticket still says “approved.” What, exactly, does that approval permit now?
The engineering decision is whether approval belongs to a mutable workflow label or to a specific execution request. For operations requiring exact review, use the latter: describe the action completely, preserve its identity, and reject a request that no longer matches. This article proposes that contract and supplies a small Python experiment. It describes no vendor capability and is not a production-ready authorization system.
Define the action before asking for approval
“Deploy checkout” leaves too much unresolved. Which environment? Which checkout resource? Which executable and arguments? Which artifact? Under whose authority, and until when?
Here is a proposed approval payload. The example identifiers and artifact digest are synthetic; they do not identify an actual deployment.
{
"schema": "action-approval-demo/v1",
"action_id": "approval-demo-001",
"target": "lab-west",
"resource": "service:checkout",
"tool": "deploy-config",
"tool_version": "1.4.2",
"arguments": ["--replicas", "3"],
"artifact_revision": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"principal": "worker:release-7",
"expires_at": "2026-11-02T18:00:00Z"
}The principal is the executor identity to which permission is delegated, not the approver. The separate grant records who approved this payload. Expiry belongs inside the hashed payload so extending the window changes what must be approved. The action ID distinguishes this request from another request with otherwise identical parameters; it does not, by itself, prevent replay.
Target and resource need identities scoped to the relevant account, tenant, and environment. A friendly name that can be reassigned is insufficient. Likewise, a tool version string helps only if execution resolves it to the intended implementation. A real adapter may need an executable digest, immutable resource identifier, explicit configuration revision, and expected resource version. Resolve defaults before review. If an environment variable can redirect the operation, that input belongs in the contract or must be constrained by the execution environment.
Show the reviewer these significant fields in readable form, alongside the relevant diff and evidence. A wall of hexadecimal does not communicate a decision. OWASP’s Transaction Authorization guidance calls for users to acknowledge significant transaction data. The proposal here applies that principle to a tool invocation: the display and the execution request must derive from the same frozen payload.
Hash a defined representation, not whatever JSON happens to print
The binding can be expressed as:
approved_digest = SHA256(canonical_bytes(approval_payload))The executor recomputes the digest from the candidate action. Any changed field invalidates this exact-action grant. Reordering object keys should not create a different action; reordering command arguments might.
Canonicalization needs a specified data model. RFC 8785 defines JSON Canonicalization Scheme, including number serialization and property ordering. It excludes duplicate object names, preserves array order, and does not perform Unicode normalization. Calling a serializer with sort_keys=True is not a general implementation of that specification.
The accompanying fixture deliberately accepts less: an exact set of fields, printable ASCII strings, and an ordered list of string arguments. It emits compact JSON with sorted keys. It rejects unknown fields, numbers, nested objects, and invalid expiry formatting. It accepts an already constructed Python dictionary, so it cannot detect duplicate keys discarded by an earlier JSON parser. There is no external JSON ingestion in this experiment.
This restricted profile makes the local result understandable, but it cannot establish interoperability across languages. A deployed protocol would need a reviewed canonicalizer or an equally explicit restricted format, parser rejection rules, schema versioning, and shared test vectors. Do not silently normalize paths, Unicode, URLs, or units differently at approval and execution.
Canonicalization also cannot decide what a resource name means. If service:checkout resolves to a different object tomorrow, the bytes can match while the operation changes. Identity resolution remains the adapter’s responsibility.
Make a changed request a new decision
For this exact-action policy, a new artifact, target, argument, tool version, principal, or expiry requires a new approval. Keep the original grant associated with its original payload; do not overwrite it with the new digest while retaining the old reviewer’s name.
A useful state progression is proposal, approved, admitted for execution, then an observed outcome. Changes before admission return the proposal to review. Rejection should identify which field changed, without exposing secrets in logs. The operator needs an actionable reason, such as “artifact differs from reviewed revision,” rather than an unexplained hash mismatch.
Exact binding has an operational cost: rebuilding an artifact can require another review even when the source diff appears unchanged. That cost buys a narrower permission. If repeated operations genuinely need delegated authority, define a different policy for a bounded class of actions, with explicit limits. Do not quietly reinterpret one exact-action grant as permission for anything attached to the same ticket.
Expiry and revocation address different problems. Expiry limits how long a grant may admit a new execution; revocation withdraws it sooner. In this proposal, equality with the expiry instant means rejection. The server supplies the clock, and clock uncertainty needs an explicit policy rather than an implicit extension of the window.
For revocation, define which component owns the decision and how fresh a worker’s view must be. If that component cannot be reached, this proposal holds new execution instead of assuming permission. Short-lived grants reduce the exposure of stale caches but do not make revocation instantaneous. Work already running needs a separate cancellation policy, and completed side effects cannot be undone by changing an approval record.
Close the gap between checking and using
Suppose the worker validates the payload, then rereads a mutable task record to construct its command. The second read can undo the entire design. This is the class of failure described by CWE-367: a checked resource changes before use, invalidating the check.
The adapter should consume the same immutable action representation it validated. It should verify fetched artifact bytes against the bound revision, select the bound tool implementation, and reject implicit substitutions. That addresses request drift; it does not freeze the outside world.
For a mutable target, consider a precondition such as “apply only if the resource remains at revision 42.” The target system must enforce that condition atomically with the write. Checking revision 42 locally and sending an unconditional write still leaves a race. A lock helps only when every relevant writer respects it and its scope covers the operation.
There is a second race around grant consumption. Two workers can both observe an unused grant before either records use. If the contract is single-use, claim it through a durable atomic transition. Then decide what happens when a worker crashes after the remote action succeeds but before recording the outcome. An idempotency mechanism or reconciliation process may be necessary; a payload hash cannot determine whether that side effect occurred.
Document the admission point precisely. A revocation received after that point may be too late to prevent an operation already accepted by the target. If the target offers neither conditional execution nor a suitable coordination mechanism, state the residual race instead of describing a preflight check as atomic authorization.
Run the small rejection experiment
The companion fixture.py uses only Python’s standard library and performs no deployment or tool execution. From its directory, run:
python3 fixture.pyIt fixes the clock at 17:59 UTC on November 2, 2026, one minute before the sample grant expires. The clock is injected so the example remains reproducible after that date. Each case compares the actual decision with an expected decision and raises an error if they differ.
| Challenge | Expected decision |
|---|---|
| Original payload and allowed caller | Accept for simulation |
| Changed target, resource, artifact, tool version, arguments, principal, or expiry | Reject changed payload |
| Original payload at exactly 18:00 UTC | Reject expired grant |
| Original payload with revoked action ID | Reject revoked grant |
| Different authenticated caller | Reject principal mismatch |
| Matching digest but disallowed approver | Reject authority |
| Reordered object keys | Accept for simulation |
| Unexpected field | Reject schema |
The recorded run checks fourteen expected outcomes. “Accept” means that this local predicate admitted its input. It does not mean that a command ran, a remote resource was protected, or a cryptographic grant was verified. The fixture also does not enforce single use, test concurrency, fetch artifacts, resolve targets, or model revocation propagation. Those are separate experiments, not properties implied by a green result here.
Use the example to review one real integration boundary. Write down the fields that can alter its behavior, where their values become immutable, how the approver sees them, and which component finally enforces the grant. Then change a target or artifact between review and dispatch and observe the rejection path. The decision worth preserving is specific: this principal may perform this action on this resource within this window. The executor must preserve that meaning all the way to the operation it actually submits.
Run the example
Download the complete Python example. Extract the archive and follow its README. The examples are synthetic demonstrations, not production benchmarks.
مواصلة القراءة
Design a verification API that cannot confuse incomplete with passed
Separate execution status, check outcomes, and missing evidence so downstream systems cannot mistake an incomplete run for permission to proceed.
When a dependency graph is incomplete, widen the test plan
Use explicit uncertainty boundaries to decide which tests an incomplete dependency map can safely exclude, with an executable fallback example.
An API change can be valid and still break its consumers
Reason about request and response compatibility separately, test actual consumer behavior, and choose a migration order that preserves the contract.
