Skip to content
OUTIS DOCS

Operation hash

Every request carries an operation_hash. It’s a fingerprint of what the operators approved: the action and its params. Outis computes it when the request is created and never changes it. Your executor computes it again from the operation it’s about to run and refuses when the two don’t match.

An approval is for one operation. The operators saw a deploy of acme/payments-api at 8d93f71 and turned their keys for that. Between the approval and the run there’s a gap. A queued job can pick up newer params in it, or a bug can hand one request’s approval to the job for another.

So the executor hashes what it’s about to do and compares that with the operation_hash on the decision. If they match, it’s running what the operators saw. If they don’t, the approval was for something else and the executor stops, which means an approval for operation A can’t be spent on operation B.

Check the hash as well as the outcome: act only when outcome is authorized and the hash matches.

The action id and the params. It leaves out who asked, when, the summary, the callback URL and the organization, so two requests for the same deploy have the same hash. The request id is what tells them apart.

The hash is SHA-256 over these bytes, in this order:

  1. The domain string outis.operation.v1 followed by one zero byte.
  2. The action id.
  3. For each param, sorted by key in bytewise order of the UTF-8 key: the key, then the value.

Every string in steps 2 and 3 is written as its UTF-8 byte length as a 4-byte big-endian unsigned integer, then its UTF-8 bytes. The domain string in step 1 has no length prefix.

The result is sha256: followed by the 64 lowercase hex digits of the digest. No params hashes the domain and the action alone.

Params are strings on the wire, so there’s nothing to normalize. Hash exactly the strings you sent: "2500.00" and "2500" are different operations.

import hashlib
import struct


def operation_hash(action: str, params: dict[str, str]) -> str:
    h = hashlib.sha256(b"outis.operation.v1\x00")

    def write(s: str) -> None:
        b = s.encode("utf-8")
        h.update(struct.pack(">I", len(b)))
        h.update(b)

    write(action)
    for key in sorted(params, key=lambda k: k.encode("utf-8")):
        write(key)
        write(params[key])
    return "sha256:" + h.hexdigest()

Pin these in your own tests. An implementation that gets all three right handles the length prefix, the key order and multibyte UTF-8.

Action Params Hash
deploy.production none sha256:da61379e121b0a00280eba6ee7d631e3c0c186e7da15ade5b8d15c901d0df8a9
deploy.production repo = acme/payments-api, env = production, sha = 8d93f71 sha256:54feb247e0ae56c01d430beb1b1c4c604384ca91832a1ccb435fe2753fb2be9e
treasury.transfer amount = 250000.00, currency = USD, note = héllo, wörld sha256:02f4614a8117cdcd0407e0d085a6fa77ceb3814a0c68ab23ce416761bf64049e
  • On the request object, from POST /v1/requests, GET /v1/requests/{id} and every callback.
  • In the audit log, on the entry for the proposal, beside a hash of the policy the request was frozen under.
  • Behind Idempotency-Key: a replay is checked against the original’s hash, and reusing a key for a different operation is a 409.