Skip to main content
[AVAILABLE], wired into the default server unconditionally. packages/api/src/bootstrap/createExecutionSystem.ts builds a real ExecutionGateway, so every POST /execute against the stock server gets the hash recompute-and-compare described below. See The gateway for the full, current mechanism.

The problem: check-vs-use (TOCTOU)

A system that authorizes an action by checking a payload, then executes a different payload under the same authorization, has a Time-Of-Check-To-Time-Of-Use gap. An authorization that only names an ID (businessTransactionId: "tx-001"), without binding to the actual content, cannot detect this: anything can be substituted under a previously-approved ID.

The real mechanism

Parmana’s SignedExecutionAuthorization binds a canonical content hash into the signed payload, not just an ID:
ExecutionGateway.verify() is where the check-vs-use gap actually closes: it recomputes the hash of the content it is about to forward, and compares it to the signed businessTransactionHash:
Verification order, side-effect-free checks first (ExecutionGateway.ts):
The nonce (single-use) check runs last, and only if every prior check, including the content hash, passed. This matters: a forged or mismatched request must never burn a nonce, or an attacker who observes a nonce in transit could poison it and get the legitimate request rejected instead (ExecutionGateway.ts, consumeNonce runs only when every check passed). ExecutableContentHasher delegates to the same TrustRecordHasher used elsewhere in the system (packages/crypto/src/ExecutableContentHasher.ts), the signing side and verifying side run identical canonical serialization and hashing, never two parallel implementations of the same computation.

The same gap, one layer out: signal freshness

businessTransactionHash closes TOCTOU for what gets executed. It says nothing about why it was approved. A decision rests on runtime signals: vendor status, risk exposure, market conditions, whatever a policy’s boundSignals/SignalStateVerifier cares about (see Policies and the decision), and those are independently verified against real-world state, but only once, before the authorization is signed. Nothing re-checked whether they were still true by the time a receiving system got around to executing: an authorization is a portable artifact, valid for authorizationTtlSeconds/maxTtlSeconds, and a system that verifies it a while after Parmana signed it had no way to tell “the vendor was verified when this was authorized” from “the vendor is verified right now.” RuntimeEngine.execute() signs a signalsHash, a canonical hash of the runtime PolicySignals the decision was evaluated against, into the payload, the direct sibling of policyContentHash above. When a SignalStateVerifier is supplied, ExecutionGateway.verify() recomputes that hash from the signals on the incoming request and compares it to the signed value (tamper/mismatch check, same shape as businessTransactionHash’s own comparison); on a match, it independently re-derives the same facts from real-world state and rejects if they’ve since diverged:
Two receiving systems can hold the exact same authorization, the exact same declared signals, and the exact same signalsHash, and still disagree, because what differs is what each one’s own live check finds right then. That is the entire point: this is a freshness check, not a tamper check. See examples/tutorials/98-signal-freshness-enforcement for both outcomes run side by side. The default server always wires one: a CompositeSignalStateVerifier (packages/api/src/application.ts) with three parts, run before the authorization is signed and again at release (stage: "release"):
  • The human approval, for every action (ApprovalSignalVerifier): the signed approval must verify for this action, resource and value. It is used (its nonce recorded) at authorization; the release check repeats every other check, such as expiry and whether the approver’s key is still trusted. See Human approval.
  • HubSpot deal state, for hubspot-deal-update (HubSpotSignalStateVerifier), fetched live from HubSpot.
  • The Slack channel, for Slack actions, against the server’s allowlist (SLACK_ALLOWED_CHANNEL_IDS).
In the default server decision and release happen in the same request, seconds apart, so the release check catches what changed in those seconds, such as an approval that just expired or an approver key just revoked. It matters most when an authorization is carried to a separate system and released later.

What this proves, precisely scoped

From CLAIMS.md 3.1 (Conditional Claim, load-bearing scope): “For any system running the Parmana envelope verifier, execution requests not authorized by Parmana are cryptographically impossible to accept. This claim holds only for a receiving system that (a) runs @parmana/envelope-verifier and (b) gates every execution-triggering code path behind its verification result. Parmana enforces nothing at the network level.” It also assumes Parmana’s authorization signing key is not compromised; see the Audit guide.
This is real, tested protection, for a gateway-integrated system. It is not a network-level guarantee, and it is not automatically true of every Parmana deployment.

The default local server enforces this

packages/api/src/bootstrap/createExecutionSystem.ts unconditionally returns createExecutionGateway(), and packages/api/src/server.ts always passes that gateway into createApplication(). There is no code path where the server skips gateway verification. See The gateway for the current, wired mechanism and its one real caveat: an action with no registered connector fails closed rather than skipping checks.

Demonstrated, at the library level

The REST API generates and verifies an authorization within the same request today, so a black-box HTTP client can’t provoke a hash mismatch through the public API alone, both sides of the check run inside the same process, on the same content, in the same call. The tamper-and-verify demonstration therefore has to happen where an authorization is a first-class object a caller can mutate before re-checking it, which is exactly what examples/tutorials/36-parameter-tampering/run.ts does: build a Business Transaction, get its real businessTransactionHash back from RuntimeBuilder, independently recompute the hash of a tampered copy of the same content (payment amount changed) using the same ExecutableContentHasher, and compare:
This is the same ExecutableContentHasher and the same comparison ExecutionGateway.verify() runs internally, exercised directly rather than through HTTP. Tutorials 35 through 46 (examples/tutorials/) cover the same negative-path pattern for replay, substitution, forgery, and expiry: mutate, re-verify, watch it fail. See Detect tampering for three of these run live with real output.

Connector SDK: the same content, unmodified, all the way to the connector

SdkConnectorExecutor (packages/execution-gateway/src/connector-execution/) sits downstream of every check above, it only ever runs after ExecutionGateway.verify() has already passed and the content is already deep frozen (ExecutionGateway.ts, deepFreeze(executableContent)). From there:
  • SdkConnectorExecutor.execute(content, credential) builds ConnectorRequest by copying businessTransactionId, action, target, and parameters straight off the verified, frozen content, never re-deriving or re-interpreting any of them.
  • The same ConnectorRequest object is what a Connector (HttpConnector, MockConnector, or a future one) receives; nothing in between constructs a second copy.
  • capability is content.action itself, so “capability” cannot silently diverge from the authorized action, there is only one field, not two that could disagree.
Any mismatch between what was authorized and what a connector would execute is still caught upstream, in the unchanged ExecutionGateway.verify() hash comparison, this is a permanent architectural invariant, not something connector-sdk re-implements or could weaken. The gateway adds capability declaration and version and health checks in front of a connector call (SdkConnectorExecutor, CapabilityConnectorPolicy in packages/execution-gateway/src/connector-runtime/), all of which fail closed (throw, never a partial or guessed success) before a connector is ever invoked.
  • Single use holds across instances in the default server. Its NonceStore is in Postgres (consumed_nonces), so every instance on the same database accepts an authorization once. A gateway you build yourself needs one shared store for the same guarantee (CLAIMS.md 3.2).
  • ML-DSA-65 (post-quantum) signatures are randomized, not deterministic. Signing the same message twice with the same key produces two different, both-valid signatures, only verification is deterministic. Don’t build tooling that assumes identical input produces an identical PQ signature (CLAIMS.md §5).
  • Every envelope carries a bounded TTL, so the window in which a carried authorization can be used is bounded, not unlimited.