Grant-bound commitment — anchor the broker's grant + response record, not the agent's story
Companion to Agent runtime and the worked ResearchAgent example. This recipe answers one specific question from authorization-broker platforms: "What would make the on-chain commitment witness our actual grant + the request/response bytes we captured, instead of whatever the agent runtime tells you?"
1. The gap this closes
The agent-runtime trail anchors a fresh-nonce commitment envelope — {version, nonce_hex, payload}, JCS-canonicalized, then sha256'd — where the agent runtime supplies payload via s.decide(...). That is the right design when the thing you are auditing is the runtime's own reasoning: ResearchAgent's job is to give an honest record of what its chain actually was, and the runtime is the authority on its own decisions.
It is the wrong design when you are an authorization broker and your headline is "the agent was permitted to do this." A misbehaving or compromised runtime can hand s.decide() a payload that says "I was granted scope X and called tool Y" — internally consistent, chain-verifiable, and decoupled from any real OAuth grant or any request/response bytes you actually captured. The commitment witnesses the agent's self-report; it does not witness the grant + response record on your side. You can't sell that to a regulator as proof the agent was permitted.
The fix does not change the protocol. It changes who assembles the payload and what goes in it: the broker — the party that issues the grant and brokers the downstream call — computes and anchors the commitment over its own record:
{session_id, oauth_grant_id, tool, scope, user_ref,
request_digest, response_digest, ts, call_index}
When the broker is a separate trust domain from the agent runtime (see the precondition below), a compromised runtime never possesses the request bytes the broker actually sent or the response bytes the broker actually captured, so it cannot forge a matching commitment. The witness moves from the agent's story to the broker's recorded request/response — the bytes the broker captured at its grant boundary.
Precondition: the broker must be a separate trust domain
This design raises the witness floor only when the broker / enforcement point is a separate trust domain from the agent runtime. If they share a process or trust domain — common, e.g. a broker that also hosts the agent's execution — then a single compromise forges the request/response bytes and the commitment together, and nothing is decoupled. In that co-located case this recipe buys you no more than the runtime-supplied commitment does: it still timestamps a record, but the record's integrity rests entirely on the one trust domain that was compromised. Adopt grant-bound commitments only when your grant boundary runs in a trust domain the agent runtime cannot reach into.
2. Why no trust is lost by moving the work to the broker
You already are the trusted boundary. You resolve the OAuth grant, you hold oauth_grant_id and the granted scope, and you broker the downstream call — so the request you send and the response you capture pass through your hands, not the runtime's. Hashing them on your side is not new trust; it is hashing bytes you already hold at the one point where they are authoritative. This is the same move as edge-hashing webhooks: verify and hash where you already control the bytes, and send Satsignal only the digests.
The wire shape is the decision commitment you already know (category: "commitment"), so this is a specialization of the agents guide's vocabulary, not a new mechanism — same envelope, same canonicalization, same anchor POST body. The only new content is the grant-bound payload.
3. The recipe
- Compute the digests at your enforcement point. When you grant a tool call and broker it,
sha256the exact request bytes you sent downstream and the exact response bytes you captured back — under separate domain prefixes so a request can never be replayed as a response. Raw bodies (OAuth tokens, request arguments, API responses) stay on your side; only the two 64-hex digests enter the payload. (Same raw-byte discipline as the webhook path: aJSON.parse → JSON.stringifyround-trip changes the bytes and breaks the digest — hash the bytes as they crossed the wire.) - Assemble the grant payload from your OWN record.
{session_id, oauth_grant_id, tool, scope, user_ref, request_digest, response_digest, ts, call_index}. Every field comes from your grant store and your call capture — never from anything the runtime hands you.call_indexis a per-call monotonic counter so a grant that brokers the same tool more than once in a session yields a distinct payload and a distinct anchor each time. - Wrap in the standard fresh-nonce envelope and anchor.
{"version": "satsignal-agent-decision-v1", "nonce_hex": <fresh 16-byte hex>, "payload": <the grant record>},canonicalize()(the agent_anchor JCS-style canonicalization),sha256the canonical bytes, andPOST /api/v1/anchorswithcategory: "commitment". The nonce is required: a grant payload is low-entropy (a tool name from a small set, a short scope list), so the nonce is what makes the on-chain hash unguessable while still pre-disclosing timing. Only{folder_slug, sha256_hex, file_size, category, label, session_id}cross the wire — never the envelope or the raw request/response. - Persist the envelope (incl.
nonce_hexandpayload) and the raw request/response bytes in your own store, one per anchored call, plus apolicy_snapshotat session start carrying the tenant's tool permissions + budget, and anevidence_bundlemanifest at session close. A verifier later recomputessha256(canonicalize(envelope)), confirms it matches the on-chain commitment, and chain-confirms the txid — exactly the existing verify-handoff path, unchanged.
4. The broker snippet (stdlib only)
This runs in the broker, at the grant-enforcement point — not in the agent runtime — and only buys you decoupling when the broker is a separate trust domain from that runtime (see §1). Its canonicalize() is byte-identical to agent_anchor.py, so existing verifiers recompute the commitment with no code change.
# Runs IN THE BROKER, at the OAuth-grant enforcement point — NOT in
# the agent runtime, and only meaningful when the broker is a SEPARATE
# trust domain from that runtime (a shared trust domain buys nothing —
# see §1). The broker already holds the grant decision and brokers the
# downstream API call, so it is the party that can record what it
# authorized and the request/response bytes it captured. stdlib only.
import hashlib, json, os, secrets, unicodedata, urllib.request
SATSIGNAL_API_KEY = os.environ["SATSIGNAL_API_KEY"]
BASE_URL = os.environ.get("SATSIGNAL_BASE_URL", "https://app.satsignal.cloud")
WIRE_VERSION = "satsignal-agent-decision-v1" # digest-load-bearing constant
def _nfc_deep(doc):
# Byte-identical to canonicalize() in agent_anchor.py / policy_snapshot.py.
if doc is None or isinstance(doc, bool):
return doc
if isinstance(doc, int):
return doc
if isinstance(doc, float):
if doc != doc or doc in (float("inf"), float("-inf")):
raise ValueError("NaN/Infinity not canonicalizable")
return doc
if isinstance(doc, str):
return unicodedata.normalize("NFC", doc)
if isinstance(doc, list):
return [_nfc_deep(v) for v in doc]
if isinstance(doc, dict):
return {unicodedata.normalize("NFC", k): _nfc_deep(v) for k, v in doc.items()}
raise TypeError(f"not canonicalizable: {type(doc).__name__}")
def canonicalize(doc) -> bytes:
return json.dumps(_nfc_deep(doc), sort_keys=True, separators=(",", ":"),
ensure_ascii=False, allow_nan=False).encode("utf-8")
def digest(raw: bytes, domain: str) -> str:
# Domain-separated: the request and response byte streams hash under
# distinct prefixes so a request can never be replayed as a response.
return hashlib.sha256(domain.encode("ascii") + b"\x00" + raw).hexdigest()
def grant_bound_commitment(*, session_id, oauth_grant_id, tool, scope,
user_ref, request_bytes, response_bytes, ts,
call_index):
# The BROKER assembles the payload from its OWN grant + call record.
# The agent runtime never touches these fields. call_index is the
# per-call monotonic counter that keeps repeated (grant, tool) calls
# in one session distinct — both in the payload and in the anchor's
# idempotency key (see anchor_commitment).
payload = {
"session_id": session_id,
"oauth_grant_id": oauth_grant_id,
"tool": tool,
"scope": scope, # list[str] of granted scopes
"user_ref": user_ref,
"request_digest": digest(request_bytes, "satsignal/grant-bound/request/v1"),
"response_digest": digest(response_bytes, "satsignal/grant-bound/response/v1"),
"ts": ts, # RFC3339Z, broker's enforcement clock
"call_index": call_index, # monotonic per-call counter
}
envelope = {
"version": WIRE_VERSION,
"nonce_hex": secrets.token_hex(16), # fresh 128-bit per call
"payload": payload,
}
canonical = canonicalize(envelope) # JCS-style, the hashed artifact
return {
"envelope": envelope, # PERSIST locally for disclosure
"canonical_bytes": canonical,
"sha256_hex": hashlib.sha256(canonical).hexdigest(),
"call_index": call_index,
}
def anchor_commitment(folder_slug, session_id, label, commit):
body = {
"folder_slug": folder_slug,
"sha256_hex": commit["sha256_hex"],
"file_size": len(commit["canonical_bytes"]),
"category": "commitment",
"label": label,
"session_id": session_id,
}
req = urllib.request.Request(
f"{BASE_URL}/api/v1/anchors",
data=json.dumps(body, separators=(",", ":")).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {SATSIGNAL_API_KEY}",
"Content-Type": "application/json",
# Per-CALL-unique idempotency key. session_id:label alone
# COLLIDES when one grant brokers the same tool twice in a
# session — the second call would silently return the first
# anchor (or 409 on the body mismatch), dropping a proof. The
# call_index suffix makes each authorized call its own anchor.
"Idempotency-Key": f"{session_id}:{label}:{commit['call_index']}",
},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read()) # {"proof_id", "txid", ...}
# --- at YOUR enforcement point, after you grant + broker one tool call ---
commit = grant_bound_commitment(
session_id="run-2026-06-13-001",
oauth_grant_id="grant_9f3c0a",
tool="gmail.send",
scope=["gmail.send"],
user_ref="user_42",
request_bytes=b'{"to":"a@b.com","subject":"hi"}', # the real request you sent
response_bytes=b'{"id":"msg_001","status":200}', # the response you captured
ts="2026-06-13T17:04:11Z",
call_index=0, # bump per authorized call
)
out = anchor_commitment("agent-runs-prod", "run-2026-06-13-001",
"grant_9f3c0a gmail.send", commit)
# out["proof_id"], out["txid"] — one commitment per authorized call.
canonicalize() here is not an RFC 8785 / JCS library and not the provenance route's SCJ-v1 — it is the agent_anchor JCS-style canonicalization (deep-NFC + sort_keys + minimal separators). Use this exact function; a different canonicalizer computes a different hash on the same input and the anchor will appear to fail.
5. What you keep, what you take on
What you keep: when the broker is a separate trust domain from the runtime (§1), the witness now binds to your recorded request/response bytes, not the agent's self-report — a compromised runtime can no longer anchor a consistent commitment, because it never holds the request/response bytes you hashed. The privacy story holds too: raw OAuth tokens, request arguments, and API responses never leave your side; only digests do. And the wire shape is unchanged, so existing agent verifiers and the verify-handoff CLI work as-is.
What you take on, versus the runtime-supplied commitment path: you instrument the anchor at your enforcement point (not inside the agent's s.decide()), and you persist the request/response bytes and the commitment envelope in your own store so a verifier can later recompute. You also choose your anchor cadence: anchor per authorized call (strong-timing — each call proven-committed before it was disclosed) when you need per-call timing, or hold commitments locally and roll them into the end-of-session evidence_bundle manifest when you care only about the decision set and your monthly anchor budget. The manifest's Merkle root — not session_id — is the cryptographic binding that ties a session's grant commitments together. Choose the runtime-supplied path when you are auditing the runtime's own reasoning; choose grant-bound commitments when the claim is "the broker recorded this grant and this response," and your grant boundary is a trust domain the runtime cannot forge.
Where the honesty line stays: this binds the commitment to the broker's record, not to a proof that the grant decision was correct policy, and not to a proof that the call actually executed. The response_digest evidences only that at time T the broker held bytes it labelled a response — a broker can fabricate response bytes (a synthesized 200, a never-sent request) and anchor a perfectly consistent commitment; Satsignal validates none of it. Nor is there a cryptographic tie between request_digest, response_digest, and oauth_grant_id beyond their co-location in one payload plus your own honesty — Satsignal anchors the digests, it does not check that they belong together. So the proof shows that at block time T you held this exact grant+request+response record and that it has not changed since — not that the authorization was appropriate, not that the call reached the provider, and not the identity of the principal. It is not an "enforcement proof." Satsignal carries no identity or authorship and validates none of the digests it anchors — it anchors the pointer, you attribute and you sign. As with every proof: it shows what existed at time T — not whether what existed was good. If a verifier needs to bind these bytes to a real-world principal, layer your own signing (Sigstore / cosign, or a signed grant) on top; Satsignal complements signing systems rather than replacing them.
A packaged broker SDK (drop-in canonicalization + request/response digesting + manifest batching at the enforcement point) is on the roadmap; the recipe above runs today on stdlib alone.
Questions about this specification? Email hello@satsignal.cloud.